Repository: langbot-app/LangBot Branch: master Commit: 3ac3fad4bc64 Files: 726 Total size: 3.1 MB Directory structure: gitextract_n8yk_ve4/ ├── .dockerignore ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yml │ │ ├── bug-report_en.yml │ │ ├── feature-request.yml │ │ ├── feature-request_en.yml │ │ ├── submit-plugin.yml │ │ └── submit-plugin_en.yml │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── build-dev-image.yaml │ ├── build-docker-image.yml │ ├── build-release-artifacts.yaml │ ├── lint.yml │ ├── publish-to-pypi.yml │ ├── run-tests.yml │ └── test-dev-image.yaml ├── .gitignore ├── .mcp.json ├── .pre-commit-config.yaml ├── AGENTS.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── README_CN.md ├── README_ES.md ├── README_FR.md ├── README_JP.md ├── README_KO.md ├── README_RU.md ├── README_TW.md ├── README_VI.md ├── codecov.yml ├── docker/ │ ├── README_K8S.md │ ├── deploy-k8s-test.sh │ ├── docker-compose.yaml │ └── kubernetes.yaml ├── docs/ │ ├── API_KEY_AUTH.md │ ├── MIGRATION_SUMMARY.md │ ├── PYPI_INSTALLATION.md │ ├── SEEKDB_INTEGRATION.md │ ├── TESTING_SUMMARY.md │ ├── WEBSOCKET_README.md │ └── service-api-openapi.json ├── main.py ├── pyproject.toml ├── pytest.ini ├── res/ │ ├── announcement.json │ ├── announcement_saved.json │ ├── instance_id.json │ └── scripts/ │ └── publish_announcement.py ├── run_tests.sh ├── src/ │ └── langbot/ │ ├── __init__.py │ ├── __main__.py │ ├── libs/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── coze_server_api/ │ │ │ ├── __init__.py │ │ │ └── client.py │ │ ├── dify_service_api/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ └── v1/ │ │ │ ├── __init__.py │ │ │ ├── client.py │ │ │ ├── client_test.py │ │ │ └── errors.py │ │ ├── dingtalk_api/ │ │ │ ├── EchoHandler.py │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── dingtalkevent.py │ │ ├── official_account_api/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── oaevent.py │ │ ├── qq_official_api/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── qqofficialevent.py │ │ ├── slack_api/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── slackevent.py │ │ ├── wechatpad_api/ │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── chatroom.py │ │ │ │ ├── downloadpai.py │ │ │ │ ├── friend.py │ │ │ │ ├── login.py │ │ │ │ ├── message.py │ │ │ │ └── user.py │ │ │ ├── client.py │ │ │ └── util/ │ │ │ ├── __init__.py │ │ │ ├── http_util.py │ │ │ └── terminal_printer.py │ │ ├── wecom_ai_bot_api/ │ │ │ ├── WXBizMsgCrypt3.py │ │ │ ├── api.py │ │ │ ├── ierror.py │ │ │ ├── wecombotevent.py │ │ │ └── ws_client.py │ │ ├── wecom_api/ │ │ │ ├── WXBizMsgCrypt3.py │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── ierror.py │ │ │ └── wecomevent.py │ │ └── wecom_customer_service_api/ │ │ ├── __init__.py │ │ ├── api.py │ │ └── wecomcsevent.py │ ├── pkg/ │ │ ├── __init__.py │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ └── http/ │ │ │ ├── __init__.py │ │ │ ├── controller/ │ │ │ │ ├── __init__.py │ │ │ │ ├── group.py │ │ │ │ ├── groups/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── apikeys.py │ │ │ │ │ ├── files.py │ │ │ │ │ ├── knowledge/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── base.py │ │ │ │ │ │ ├── engines.py │ │ │ │ │ │ ├── migration.py │ │ │ │ │ │ └── parsers.py │ │ │ │ │ ├── logs.py │ │ │ │ │ ├── monitoring.py │ │ │ │ │ ├── pipelines/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── pipelines.py │ │ │ │ │ │ └── websocket_chat.py │ │ │ │ │ ├── platform/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── adapters.py │ │ │ │ │ │ └── bots.py │ │ │ │ │ ├── plugins.py │ │ │ │ │ ├── provider/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── models.py │ │ │ │ │ │ ├── providers.py │ │ │ │ │ │ └── requesters.py │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ └── mcp.py │ │ │ │ │ ├── stats.py │ │ │ │ │ ├── survey.py │ │ │ │ │ ├── system.py │ │ │ │ │ ├── user.py │ │ │ │ │ ├── webhook_mgmt.py │ │ │ │ │ └── webhooks.py │ │ │ │ └── main.py │ │ │ └── service/ │ │ │ ├── __init__.py │ │ │ ├── apikey.py │ │ │ ├── bot.py │ │ │ ├── knowledge.py │ │ │ ├── mcp.py │ │ │ ├── model.py │ │ │ ├── monitoring.py │ │ │ ├── pipeline.py │ │ │ ├── provider.py │ │ │ ├── space.py │ │ │ ├── user.py │ │ │ └── webhook.py │ │ ├── command/ │ │ │ ├── __init__.py │ │ │ ├── cmdmgr.py │ │ │ ├── operator.py │ │ │ └── operators/ │ │ │ ├── __init__.py │ │ │ ├── delc.py │ │ │ ├── last.py │ │ │ ├── list.py │ │ │ ├── next.py │ │ │ ├── prompt.py │ │ │ └── resend.py │ │ ├── config/ │ │ │ ├── __init__.py │ │ │ ├── impls/ │ │ │ │ ├── __init__.py │ │ │ │ ├── json.py │ │ │ │ ├── pymodule.py │ │ │ │ └── yaml.py │ │ │ ├── manager.py │ │ │ └── model.py │ │ ├── core/ │ │ │ ├── __init__.py │ │ │ ├── app.py │ │ │ ├── boot.py │ │ │ ├── bootutils/ │ │ │ │ ├── __init__.py │ │ │ │ ├── config.py │ │ │ │ ├── deps.py │ │ │ │ ├── files.py │ │ │ │ └── log.py │ │ │ ├── entities.py │ │ │ ├── migration.py │ │ │ ├── migrations/ │ │ │ │ ├── __init__.py │ │ │ │ ├── m001_sensitive_word_migration.py │ │ │ │ ├── m002_openai_config_migration.py │ │ │ │ ├── m003_anthropic_requester_cfg_completion.py │ │ │ │ ├── m004_moonshot_cfg_completion.py │ │ │ │ ├── m005_deepseek_cfg_completion.py │ │ │ │ ├── m006_vision_config.py │ │ │ │ ├── m007_qcg_center_url.py │ │ │ │ ├── m008_ad_fixwin_config_migrate.py │ │ │ │ ├── m009_msg_truncator_cfg.py │ │ │ │ ├── m010_ollama_requester_config.py │ │ │ │ ├── m011_command_prefix_config.py │ │ │ │ ├── m012_runner_config.py │ │ │ │ ├── m013_http_api_config.py │ │ │ │ ├── m014_force_delay_config.py │ │ │ │ ├── m015_gitee_ai_config.py │ │ │ │ ├── m016_dify_service_api.py │ │ │ │ ├── m017_dify_api_timeout_params.py │ │ │ │ ├── m018_xai_config.py │ │ │ │ ├── m019_zhipuai_config.py │ │ │ │ ├── m020_wecom_config.py │ │ │ │ ├── m021_lark_config.py │ │ │ │ ├── m022_lmstudio_config.py │ │ │ │ ├── m023_siliconflow_config.py │ │ │ │ ├── m024_discord_config.py │ │ │ │ ├── m025_gewechat_config.py │ │ │ │ ├── m026_qqofficial_config.py │ │ │ │ ├── m027_wx_official_account_config.py │ │ │ │ ├── m028_aliyun_requester_config.py │ │ │ │ ├── m029_dashscope_app_api_config.py │ │ │ │ ├── m030_lark_config_cmpl.py │ │ │ │ ├── m031_dingtalk_config.py │ │ │ │ ├── m032_volcark_config.py │ │ │ │ ├── m033_dify_thinking_config.py │ │ │ │ ├── m034_gewechat_file_url_config.py │ │ │ │ ├── m035_wxoa_mode.py │ │ │ │ ├── m036_wxoa_loading_message.py │ │ │ │ ├── m037_mcp_config.py │ │ │ │ ├── m038_tg_dingtalk_markdown.py │ │ │ │ ├── m039_modelscope_cfg_completion.py │ │ │ │ ├── m040_ppio_config.py │ │ │ │ └── m041_dingtalk_card_autolayout_config.py │ │ │ ├── note.py │ │ │ ├── notes/ │ │ │ │ ├── __init__.py │ │ │ │ ├── n001_classic_msgs.py │ │ │ │ ├── n002_selection_mode_on_windows.py │ │ │ │ └── n003_print_version.py │ │ │ ├── stage.py │ │ │ ├── stages/ │ │ │ │ ├── __init__.py │ │ │ │ ├── build_app.py │ │ │ │ ├── genkeys.py │ │ │ │ ├── load_config.py │ │ │ │ ├── migrate.py │ │ │ │ ├── setup_logger.py │ │ │ │ └── show_notes.py │ │ │ └── taskmgr.py │ │ ├── discover/ │ │ │ ├── __init__.py │ │ │ └── engine.py │ │ ├── entity/ │ │ │ ├── __init__.py │ │ │ ├── dto/ │ │ │ │ ├── __init__.py │ │ │ │ └── space_model.py │ │ │ ├── errors/ │ │ │ │ ├── __init__.py │ │ │ │ ├── account.py │ │ │ │ ├── platform.py │ │ │ │ └── provider.py │ │ │ └── persistence/ │ │ │ ├── __init__.py │ │ │ ├── apikey.py │ │ │ ├── base.py │ │ │ ├── bot.py │ │ │ ├── bstorage.py │ │ │ ├── mcp.py │ │ │ ├── metadata.py │ │ │ ├── model.py │ │ │ ├── monitoring.py │ │ │ ├── pipeline.py │ │ │ ├── plugin.py │ │ │ ├── rag.py │ │ │ ├── user.py │ │ │ ├── vector.py │ │ │ └── webhook.py │ │ ├── persistence/ │ │ │ ├── __init__.py │ │ │ ├── database.py │ │ │ ├── databases/ │ │ │ │ ├── __init__.py │ │ │ │ ├── postgresql.py │ │ │ │ └── sqlite.py │ │ │ ├── mgr.py │ │ │ ├── migration.py │ │ │ └── migrations/ │ │ │ ├── __init__.py │ │ │ ├── dbm001_migrate_v3_config.py │ │ │ ├── dbm002_combine_quote_msg_config.py │ │ │ ├── dbm003_n8n_config.py │ │ │ ├── dbm004_rag_kb_uuid.py │ │ │ ├── dbm005_pipeline_remove_cot_config.py │ │ │ ├── dbm006_langflow_api_config.py │ │ │ ├── dbm007_plugin_install_source.py │ │ │ ├── dbm008_plugin_config.py │ │ │ ├── dbm009_pipeline_extension_preferences.py │ │ │ ├── dbm010_pipeline_multi_knowledge_base.py │ │ │ ├── dbm011_dify_base_prompt_config.py │ │ │ ├── dbm012_pipeline_extensions_enable_all.py │ │ │ ├── dbm013_knowledge_base_updated_at.py │ │ │ ├── dbm014_space_account_support.py │ │ │ ├── dbm015_model_source_tracking.py │ │ │ ├── dbm016_model_provider_refactor.py │ │ │ ├── dbm017_move_cloud_service_url.py │ │ │ ├── dbm018_add_emoji_support.py │ │ │ ├── dbm019_monitoring_message_role.py │ │ │ ├── dbm020_knowledge_engine_plugin_architecture.py │ │ │ ├── dbm021_merge_exception_handling.py │ │ │ ├── dbm022_monitoring_user_name.py │ │ │ ├── dbm023_model_fallback_config.py │ │ │ └── dbm024_wecombot_websocket_mode.py │ │ ├── pipeline/ │ │ │ ├── __init__.py │ │ │ ├── aggregator.py │ │ │ ├── bansess/ │ │ │ │ ├── __init__.py │ │ │ │ └── bansess.py │ │ │ ├── cntfilter/ │ │ │ │ ├── __init__.py │ │ │ │ ├── cntfilter.py │ │ │ │ ├── entities.py │ │ │ │ ├── filter.py │ │ │ │ └── filters/ │ │ │ │ ├── __init__.py │ │ │ │ ├── baiduexamine.py │ │ │ │ ├── banwords.py │ │ │ │ └── cntignore.py │ │ │ ├── config_coercion.py │ │ │ ├── controller.py │ │ │ ├── entities.py │ │ │ ├── longtext/ │ │ │ │ ├── __init__.py │ │ │ │ ├── longtext.py │ │ │ │ ├── strategies/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── forward.py │ │ │ │ │ └── image.py │ │ │ │ └── strategy.py │ │ │ ├── monitoring_helper.py │ │ │ ├── msgtrun/ │ │ │ │ ├── __init__.py │ │ │ │ ├── msgtrun.py │ │ │ │ ├── truncator.py │ │ │ │ └── truncators/ │ │ │ │ ├── __init__.py │ │ │ │ └── round.py │ │ │ ├── pipelinemgr.py │ │ │ ├── pool.py │ │ │ ├── preproc/ │ │ │ │ ├── __init__.py │ │ │ │ └── preproc.py │ │ │ ├── process/ │ │ │ │ ├── __init__.py │ │ │ │ ├── handler.py │ │ │ │ ├── handlers/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── chat.py │ │ │ │ │ └── command.py │ │ │ │ └── process.py │ │ │ ├── ratelimit/ │ │ │ │ ├── __init__.py │ │ │ │ ├── algo.py │ │ │ │ ├── algos/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── fixedwin.py │ │ │ │ └── ratelimit.py │ │ │ ├── respback/ │ │ │ │ ├── __init__.py │ │ │ │ └── respback.py │ │ │ ├── resprule/ │ │ │ │ ├── __init__.py │ │ │ │ ├── entities.py │ │ │ │ ├── resprule.py │ │ │ │ ├── rule.py │ │ │ │ └── rules/ │ │ │ │ ├── __init__.py │ │ │ │ ├── atbot.py │ │ │ │ ├── prefix.py │ │ │ │ ├── random.py │ │ │ │ └── regexp.py │ │ │ ├── stage.py │ │ │ └── wrapper/ │ │ │ ├── __init__.py │ │ │ └── wrapper.py │ │ ├── platform/ │ │ │ ├── __init__.py │ │ │ ├── botmgr.py │ │ │ ├── logger.py │ │ │ ├── sources/ │ │ │ │ ├── __init__.py │ │ │ │ ├── aiocqhttp.py │ │ │ │ ├── aiocqhttp.yaml │ │ │ │ ├── dingtalk.py │ │ │ │ ├── dingtalk.yaml │ │ │ │ ├── discord.py │ │ │ │ ├── discord.yaml │ │ │ │ ├── kook.py │ │ │ │ ├── kook.yaml │ │ │ │ ├── lark.py │ │ │ │ ├── lark.yaml │ │ │ │ ├── legacy/ │ │ │ │ │ ├── gewechat.py │ │ │ │ │ ├── gewechat.yaml │ │ │ │ │ ├── nakuru.py │ │ │ │ │ ├── nakuru.yaml │ │ │ │ │ ├── qqbotpy.py │ │ │ │ │ └── qqbotpy.yaml │ │ │ │ ├── line.py │ │ │ │ ├── line.yaml │ │ │ │ ├── officialaccount.py │ │ │ │ ├── officialaccount.yaml │ │ │ │ ├── qqofficial.py │ │ │ │ ├── qqofficial.yaml │ │ │ │ ├── satori.py │ │ │ │ ├── satori.yaml │ │ │ │ ├── slack.py │ │ │ │ ├── slack.yaml │ │ │ │ ├── telegram.py │ │ │ │ ├── telegram.yaml │ │ │ │ ├── websocket.yaml │ │ │ │ ├── websocket_adapter.py │ │ │ │ ├── websocket_manager.py │ │ │ │ ├── wechatpad.py │ │ │ │ ├── wechatpad.yaml │ │ │ │ ├── wecom.py │ │ │ │ ├── wecom.yaml │ │ │ │ ├── wecombot.py │ │ │ │ ├── wecombot.yaml │ │ │ │ ├── wecomcs.py │ │ │ │ └── wecomcs.yaml │ │ │ └── webhook_pusher.py │ │ ├── plugin/ │ │ │ ├── __init__.py │ │ │ ├── connector.py │ │ │ └── handler.py │ │ ├── provider/ │ │ │ ├── __init__.py │ │ │ ├── modelmgr/ │ │ │ │ ├── __init__.py │ │ │ │ ├── entities.py │ │ │ │ ├── errors.py │ │ │ │ ├── modelmgr.py │ │ │ │ ├── requester.py │ │ │ │ ├── requester.yaml │ │ │ │ ├── requesters/ │ │ │ │ │ ├── 302aichatcmpl.py │ │ │ │ │ ├── 302aichatcmpl.yaml │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── anthropicmsgs.py │ │ │ │ │ ├── anthropicmsgs.yaml │ │ │ │ │ ├── bailianchatcmpl.py │ │ │ │ │ ├── bailianchatcmpl.yaml │ │ │ │ │ ├── chatcmpl.py │ │ │ │ │ ├── chatcmpl.yaml │ │ │ │ │ ├── compsharechatcmpl.py │ │ │ │ │ ├── compsharechatcmpl.yaml │ │ │ │ │ ├── deepseekchatcmpl.py │ │ │ │ │ ├── deepseekchatcmpl.yaml │ │ │ │ │ ├── geminichatcmpl.py │ │ │ │ │ ├── geminichatcmpl.yaml │ │ │ │ │ ├── giteeaichatcmpl.py │ │ │ │ │ ├── giteeaichatcmpl.yaml │ │ │ │ │ ├── jiekouaichatcmpl.py │ │ │ │ │ ├── jiekouaichatcmpl.yaml │ │ │ │ │ ├── lmstudiochatcmpl.py │ │ │ │ │ ├── lmstudiochatcmpl.yaml │ │ │ │ │ ├── modelscopechatcmpl.py │ │ │ │ │ ├── modelscopechatcmpl.yaml │ │ │ │ │ ├── moonshotchatcmpl.py │ │ │ │ │ ├── moonshotchatcmpl.yaml │ │ │ │ │ ├── newapichatcmpl.py │ │ │ │ │ ├── newapichatcmpl.yaml │ │ │ │ │ ├── ollamachat.py │ │ │ │ │ ├── ollamachat.yaml │ │ │ │ │ ├── openrouterchatcmpl.py │ │ │ │ │ ├── openrouterchatcmpl.yaml │ │ │ │ │ ├── ppiochatcmpl.py │ │ │ │ │ ├── ppiochatcmpl.yaml │ │ │ │ │ ├── qhaigcchatcmpl.py │ │ │ │ │ ├── qhaigcchatcmpl.yaml │ │ │ │ │ ├── seekdbembed.py │ │ │ │ │ ├── seekdbembed.yaml │ │ │ │ │ ├── shengsuanyun.py │ │ │ │ │ ├── shengsuanyun.yaml │ │ │ │ │ ├── siliconflowchatcmpl.py │ │ │ │ │ ├── siliconflowchatcmpl.yaml │ │ │ │ │ ├── spacechatcmpl.py │ │ │ │ │ ├── spacechatcmpl.yaml │ │ │ │ │ ├── tokenpony.yaml │ │ │ │ │ ├── tokenponychatcmpl.py │ │ │ │ │ ├── volcarkchatcmpl.py │ │ │ │ │ ├── volcarkchatcmpl.yaml │ │ │ │ │ ├── xaichatcmpl.py │ │ │ │ │ ├── xaichatcmpl.yaml │ │ │ │ │ ├── zhipuaichatcmpl.py │ │ │ │ │ └── zhipuaichatcmpl.yaml │ │ │ │ └── token.py │ │ │ ├── runner.py │ │ │ ├── runners/ │ │ │ │ ├── __init__.py │ │ │ │ ├── cozeapi.py │ │ │ │ ├── dashscopeapi.py │ │ │ │ ├── difysvapi.py │ │ │ │ ├── langflowapi.py │ │ │ │ ├── localagent.py │ │ │ │ ├── n8nsvapi.py │ │ │ │ └── tboxapi.py │ │ │ ├── session/ │ │ │ │ ├── __init__.py │ │ │ │ └── sessionmgr.py │ │ │ └── tools/ │ │ │ ├── __init__.py │ │ │ ├── loader.py │ │ │ ├── loaders/ │ │ │ │ ├── __init__.py │ │ │ │ ├── mcp.py │ │ │ │ └── plugin.py │ │ │ └── toolmgr.py │ │ ├── rag/ │ │ │ ├── knowledge/ │ │ │ │ ├── base.py │ │ │ │ └── kbmgr.py │ │ │ └── service/ │ │ │ ├── __init__.py │ │ │ └── runtime.py │ │ ├── storage/ │ │ │ ├── __init__.py │ │ │ ├── mgr.py │ │ │ ├── provider.py │ │ │ └── providers/ │ │ │ ├── __init__.py │ │ │ ├── localstorage.py │ │ │ └── s3storage.py │ │ ├── survey/ │ │ │ ├── __init__.py │ │ │ └── manager.py │ │ ├── telemetry/ │ │ │ ├── __init__.py │ │ │ └── telemetry.py │ │ ├── utils/ │ │ │ ├── __init__.py │ │ │ ├── constants.py │ │ │ ├── funcschema.py │ │ │ ├── httpclient.py │ │ │ ├── image.py │ │ │ ├── importutil.py │ │ │ ├── logcache.py │ │ │ ├── paths.py │ │ │ ├── pkgmgr.py │ │ │ ├── platform.py │ │ │ ├── proxy.py │ │ │ ├── runner.py │ │ │ └── version.py │ │ └── vector/ │ │ ├── __init__.py │ │ ├── filter_utils.py │ │ ├── mgr.py │ │ ├── vdb.py │ │ └── vdbs/ │ │ ├── __init__.py │ │ ├── chroma.py │ │ ├── milvus.py │ │ ├── pgvector_db.py │ │ ├── qdrant.py │ │ └── seekdb.py │ └── templates/ │ ├── __init__.py │ ├── components.yaml │ ├── config.yaml │ ├── default-pipeline-config.json │ ├── legacy/ │ │ ├── command.json │ │ ├── pipeline.json │ │ ├── platform.json │ │ ├── provider.json │ │ └── system.json │ └── metadata/ │ ├── pipeline/ │ │ ├── ai.yaml │ │ ├── output.yaml │ │ ├── safety.yaml │ │ └── trigger.yaml │ └── sensitive-words.json ├── tests/ │ ├── README.md │ ├── __init__.py │ └── unit_tests/ │ ├── __init__.py │ ├── config/ │ │ ├── __init__.py │ │ ├── test_env_override.py │ │ └── test_webhook_display_prefix.py │ ├── pipeline/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_bansess.py │ │ ├── test_config_coercion.py │ │ ├── test_pipelinemgr.py │ │ ├── test_ratelimit.py │ │ ├── test_resprule.py │ │ └── test_simple.py │ ├── plugin/ │ │ ├── __init__.py │ │ ├── test_plugin_component_filtering.py │ │ └── test_plugin_list_sorting.py │ └── storage/ │ ├── __init__.py │ └── test_storage_provider_selection.py └── web/ ├── .env.example ├── .gitignore ├── .lintstagedrc.json ├── .prettierrc.mjs ├── README.md ├── components.json ├── eslint.config.mjs ├── next ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── src/ │ ├── app/ │ │ ├── auth/ │ │ │ └── space/ │ │ │ └── callback/ │ │ │ └── page.tsx │ │ ├── global.css │ │ ├── home/ │ │ │ ├── bots/ │ │ │ │ ├── BotDetailDialog.tsx │ │ │ │ ├── botConfig.module.css │ │ │ │ ├── components/ │ │ │ │ │ ├── bot-card/ │ │ │ │ │ │ ├── BotCard.tsx │ │ │ │ │ │ ├── BotCardVO.ts │ │ │ │ │ │ └── botCard.module.css │ │ │ │ │ ├── bot-form/ │ │ │ │ │ │ ├── BotForm.tsx │ │ │ │ │ │ └── ChooseEntity.ts │ │ │ │ │ ├── bot-log/ │ │ │ │ │ │ ├── BotLogManager.ts │ │ │ │ │ │ └── view/ │ │ │ │ │ │ ├── BotLogCard.tsx │ │ │ │ │ │ ├── BotLogListComponent.tsx │ │ │ │ │ │ └── botLog.module.css │ │ │ │ │ └── bot-session/ │ │ │ │ │ └── BotSessionMonitor.tsx │ │ │ │ └── page.tsx │ │ │ ├── components/ │ │ │ │ ├── account-settings-dialog/ │ │ │ │ │ └── AccountSettingsDialog.tsx │ │ │ │ ├── api-integration-dialog/ │ │ │ │ │ └── ApiIntegrationDialog.tsx │ │ │ │ ├── dynamic-form/ │ │ │ │ │ ├── DynamicFormComponent.tsx │ │ │ │ │ ├── DynamicFormItemComponent.tsx │ │ │ │ │ ├── DynamicFormItemConfig.ts │ │ │ │ │ └── N8nAuthFormComponent.tsx │ │ │ │ ├── home-sidebar/ │ │ │ │ │ ├── HomeSidebar.module.css │ │ │ │ │ ├── HomeSidebar.tsx │ │ │ │ │ ├── HomeSidebarChild.tsx │ │ │ │ │ └── sidbarConfigList.tsx │ │ │ │ ├── home-titlebar/ │ │ │ │ │ ├── HomeTitleBar.tsx │ │ │ │ │ └── HomeTittleBar.module.css │ │ │ │ ├── models-dialog/ │ │ │ │ │ ├── ModelsDialog.tsx │ │ │ │ │ ├── component/ │ │ │ │ │ │ └── provider-form/ │ │ │ │ │ │ └── ProviderForm.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── AddModelPopover.tsx │ │ │ │ │ │ ├── ExtraArgsEditor.tsx │ │ │ │ │ │ ├── ModelItem.tsx │ │ │ │ │ │ ├── ProviderCard.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── new-version-dialog/ │ │ │ │ │ └── NewVersionDialog.tsx │ │ │ │ ├── password-change-dialog/ │ │ │ │ │ └── PasswordChangeDialog.tsx │ │ │ │ └── survey/ │ │ │ │ └── SurveyWidget.tsx │ │ │ ├── knowledge/ │ │ │ │ ├── KBDetailDialog.tsx │ │ │ │ ├── components/ │ │ │ │ │ ├── kb-card/ │ │ │ │ │ │ ├── KBCard.module.css │ │ │ │ │ │ ├── KBCard.tsx │ │ │ │ │ │ └── KBCardVO.ts │ │ │ │ │ ├── kb-docs/ │ │ │ │ │ │ ├── FileUploadZone.tsx │ │ │ │ │ │ ├── KBDoc.tsx │ │ │ │ │ │ └── documents/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ └── data-table.tsx │ │ │ │ │ ├── kb-form/ │ │ │ │ │ │ ├── ChooseEntity.ts │ │ │ │ │ │ └── KBForm.tsx │ │ │ │ │ ├── kb-migration-dialog/ │ │ │ │ │ │ └── KBMigrationDialog.tsx │ │ │ │ │ └── kb-retrieve/ │ │ │ │ │ └── KBRetrieveGeneric.tsx │ │ │ │ ├── knowledgeBase.module.css │ │ │ │ └── page.tsx │ │ │ ├── layout.module.css │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ ├── monitoring/ │ │ │ │ ├── components/ │ │ │ │ │ ├── ExportDropdown.tsx │ │ │ │ │ ├── MessageContentRenderer.tsx │ │ │ │ │ ├── MessageDetailsCard.tsx │ │ │ │ │ ├── filters/ │ │ │ │ │ │ └── MonitoringFilters.tsx │ │ │ │ │ └── overview-cards/ │ │ │ │ │ ├── MetricCard.tsx │ │ │ │ │ ├── OverviewCards.tsx │ │ │ │ │ └── TrafficChart.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── useMonitoringData.ts │ │ │ │ │ └── useMonitoringFilters.ts │ │ │ │ ├── page.tsx │ │ │ │ ├── types/ │ │ │ │ │ └── monitoring.ts │ │ │ │ └── utils/ │ │ │ │ └── dateUtils.ts │ │ │ ├── page.tsx │ │ │ ├── pipelines/ │ │ │ │ ├── PipelineDetailDialog.tsx │ │ │ │ ├── components/ │ │ │ │ │ ├── debug-dialog/ │ │ │ │ │ │ ├── AtBadge.tsx │ │ │ │ │ │ ├── DebugDialog.tsx │ │ │ │ │ │ └── ImagePreviewDialog.tsx │ │ │ │ │ ├── monitoring-tab/ │ │ │ │ │ │ └── PipelineMonitoringTab.tsx │ │ │ │ │ ├── pipeline-card/ │ │ │ │ │ │ ├── PipelineCard.tsx │ │ │ │ │ │ ├── PipelineCardVO.ts │ │ │ │ │ │ └── pipelineCard.module.css │ │ │ │ │ ├── pipeline-extensions/ │ │ │ │ │ │ └── PipelineExtension.tsx │ │ │ │ │ └── pipeline-form/ │ │ │ │ │ ├── PipelineFormComponent.tsx │ │ │ │ │ └── pipelineFormStyle.module.css │ │ │ │ ├── page.tsx │ │ │ │ └── pipelineConfig.module.css │ │ │ └── plugins/ │ │ │ ├── components/ │ │ │ │ ├── plugin-installed/ │ │ │ │ │ ├── PluginCardVO.ts │ │ │ │ │ ├── PluginComponentList.tsx │ │ │ │ │ ├── PluginInstalledComponent.tsx │ │ │ │ │ ├── plugin-card/ │ │ │ │ │ │ └── PluginCardComponent.tsx │ │ │ │ │ ├── plugin-form/ │ │ │ │ │ │ └── PluginForm.tsx │ │ │ │ │ └── plugin-readme/ │ │ │ │ │ └── PluginReadme.tsx │ │ │ │ └── plugin-market/ │ │ │ │ ├── PluginMarketComponent.tsx │ │ │ │ ├── RecommendationLists.tsx │ │ │ │ ├── TagsFilter.tsx │ │ │ │ └── plugin-market-card/ │ │ │ │ ├── PluginMarketCardComponent.tsx │ │ │ │ └── PluginMarketCardVO.ts │ │ │ ├── mcp-server/ │ │ │ │ ├── MCPCardVO.ts │ │ │ │ ├── MCPServerComponent.tsx │ │ │ │ ├── mcp-card/ │ │ │ │ │ └── MCPCardComponent.tsx │ │ │ │ └── mcp-form/ │ │ │ │ ├── MCPDeleteConfirmDialog.tsx │ │ │ │ └── MCPFormDialog.tsx │ │ │ ├── page.tsx │ │ │ └── plugins.module.css │ │ ├── infra/ │ │ │ ├── basic-component/ │ │ │ │ └── create-card-component/ │ │ │ │ ├── CreateCardComponent.tsx │ │ │ │ └── createCartComponent.module.css │ │ │ ├── entities/ │ │ │ │ ├── api/ │ │ │ │ │ └── index.ts │ │ │ │ ├── common.ts │ │ │ │ ├── form/ │ │ │ │ │ └── dynamic.ts │ │ │ │ ├── message/ │ │ │ │ │ └── index.ts │ │ │ │ ├── pipeline/ │ │ │ │ │ └── index.ts │ │ │ │ └── plugin/ │ │ │ │ └── index.ts │ │ │ ├── http/ │ │ │ │ ├── BackendClient.ts │ │ │ │ ├── BaseHttpClient.ts │ │ │ │ ├── CloudServiceClient.ts │ │ │ │ ├── HttpClient.ts │ │ │ │ ├── README.md │ │ │ │ ├── index.ts │ │ │ │ └── requestParam/ │ │ │ │ └── bots/ │ │ │ │ ├── GetBotLogsRequest.ts │ │ │ │ └── GetBotLogsResponse.ts │ │ │ └── websocket/ │ │ │ └── WebSocketClient.ts │ │ ├── layout.tsx │ │ ├── login/ │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── register/ │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── reset-password/ │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ └── utils/ │ │ └── versionCompare.ts │ ├── components/ │ │ ├── providers/ │ │ │ └── theme-provider.tsx │ │ └── ui/ │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── emoji-picker.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── item.tsx │ │ ├── label.tsx │ │ ├── language-selector.tsx │ │ ├── loading-spinner.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── theme-toggle.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx │ ├── hooks/ │ │ ├── use-mobile.ts │ │ └── useAsyncTask.ts │ ├── i18n/ │ │ ├── I18nProvider.tsx │ │ ├── index.ts │ │ └── locales/ │ │ ├── en-US.ts │ │ ├── ja-JP.ts │ │ ├── zh-Hans.ts │ │ └── zh-Hant.ts │ ├── i18next.d.ts │ ├── lib/ │ │ └── utils.ts │ └── styles/ │ └── github-markdown.css ├── tsconfig.json └── web@0.1.0 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .github .venv .vscode .data .temp web/.next web/node_modules web/.env ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yml ================================================ name: 漏洞反馈 description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://docs.langbot.app/zh/workshop/network-details.html title: "[Bug]: " labels: ["bug?"] body: - type: input attributes: label: 运行环境 description: LangBot 版本、操作系统、系统架构、**Python版本**、**主机地理位置** placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker validations: required: true - type: textarea attributes: label: 异常情况 description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。** validations: required: true - type: textarea attributes: label: 复现步骤 description: 提供越多信息,我们会越快解决问题,建议多提供配置截图;**如果涉及 Dify、n8n、Langflow 等外部平台,请提供应用的导出文件(如 Dify 应用的 DSL),我们将更快回复您。** validations: required: false - type: textarea attributes: label: 启用的插件 description: 有些情况可能和插件功能有关,建议提供插件启用情况。 validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report_en.yml ================================================ name: Bug report description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://docs.langbot.app/en/workshop/network-details.html title: "[Bug]: " labels: ["bug?"] body: - type: input attributes: label: Runtime environment description: LangBot version, operating system, system architecture, **Python version**, **host location** placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker" validations: required: true - type: textarea attributes: label: Exception description: Describe the exception in detail, what happened and when it happened. **Please include log information.** validations: required: true - type: textarea attributes: label: Reproduction steps description: How to reproduce this problem, the more detailed the better; the more information you provide, the faster we will solve the problem. validations: required: false - type: textarea attributes: label: Enabled plugins description: Some cases may be related to plugin functionality, so please provide the plugin enablement status. validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature-request.yml ================================================ name: 需求建议 title: "[Feature]: " labels: [] description: "【供中文用户】新功能或现有功能优化请使用这个模板;不符合类别的issue将被直接关闭" body: - type: dropdown attributes: label: 这是一个? description: 新功能建议还是现有功能优化 options: - 新功能 - 现有功能优化 validations: required: true - type: textarea attributes: label: 详细描述 description: 详细描述,越详细越好 validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/feature-request_en.yml ================================================ name: Feature request title: "[Feature]: " labels: [] description: "New features or existing feature improvements should use this template; issues that do not match will be closed directly" body: - type: dropdown attributes: label: This is a? description: New feature request or existing feature improvement options: - New feature - Existing feature improvement validations: required: true - type: textarea attributes: label: Detailed description description: Detailed description, the more detailed the better validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/submit-plugin.yml ================================================ name: 提交新插件 title: "[Plugin]: 请求登记新插件" labels: ["独立插件"] description: "【供中文用户】本模板供且仅供提交新插件使用" body: - type: input attributes: label: 插件名称 description: 填写插件的名称 validations: required: true - type: textarea attributes: label: 插件代码库地址 description: 仅支持 Github validations: required: true - type: textarea attributes: label: 插件简介 description: 插件的简介 validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/submit-plugin_en.yml ================================================ name: Submit a new plugin title: "[Plugin]: Request to register a new plugin" labels: ["Independent Plugin"] description: "This template is only for submitting new plugins" body: - type: input attributes: label: Plugin name description: Fill in the name of the plugin validations: required: true - type: textarea attributes: label: Plugin code repository address description: Only support Github validations: required: true - type: textarea attributes: label: Plugin description description: The description of the plugin validations: required: true ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" allow: - dependency-name: "openai" ================================================ FILE: .github/pull_request_template.md ================================================ ## 概述 / Overview > 请在此部分填写你实现/解决/优化的内容: > Summary of what you implemented/solved/optimized: > ### 更改前后对比截图 / Screenshots > 请在此部分粘贴更改前后对比截图(可以是界面截图、控制台输出、对话截图等): > Please paste the screenshots of changes before and after here (can be interface screenshots, console output, conversation screenshots, etc.): > > 修改前 / Before: > > 修改后 / After: > ## 检查清单 / Checklist ### PR 作者完成 / For PR author *请在方括号间写`x`以打勾 / Please tick the box with `x`* - [ ] 阅读仓库[贡献指引](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)? - [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer? - [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected. ### 项目维护者完成 / For project maintainer - [ ] 相关 issues 链接了吗? / Have you linked the related issues? - [ ] 配置项写好了吗?迁移写好了吗?生效了吗? / Have you written the configuration items? Have you written the migration? Has it taken effect? - [ ] 依赖加到 pyproject.toml 和 core/bootutils/deps.py 了吗 / Have you added the dependencies to pyproject.toml and core/bootutils/deps.py? - [ ] 文档编写了吗? / Have you written the documentation? ================================================ FILE: .github/workflows/build-dev-image.yaml ================================================ name: Build Dev Image on: push: workflow_dispatch: jobs: build-dev-image: runs-on: ubuntu-latest # 如果是tag则跳过 if: ${{ !startsWith(github.ref, 'refs/tags/') }} steps: - name: Checkout uses: actions/checkout@v2 with: persist-credentials: false - name: Generate Tag id: generate_tag run: | # 获取分支名称,把/替换为- echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g' echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g') - name: Login to Registry run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }} - name: Build Docker Image run: | docker buildx create --name mybuilder --use docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push ================================================ FILE: .github/workflows/build-docker-image.yml ================================================ name: Build Docker Image on: ## 发布release的时候会自动构建 release: types: [published] jobs: publish-docker-image: runs-on: ubuntu-latest name: Build image steps: - name: Checkout uses: actions/checkout@v2 with: persist-credentials: false - name: judge has env GITHUB_REF # 如果没有GITHUB_REF环境变量,则把github.ref变量赋值给GITHUB_REF run: | if [ -z "$GITHUB_REF" ]; then export GITHUB_REF=${{ github.ref }} echo $GITHUB_REF fi - name: Check version id: check_version run: | echo $GITHUB_REF # 如果是tag,则去掉refs/tags/前缀 if [[ $GITHUB_REF == refs/tags/* ]]; then echo "It's a tag" echo $GITHUB_REF echo $GITHUB_REF | awk -F '/' '{print $3}' echo ::set-output name=version::$(echo $GITHUB_REF | awk -F '/' '{print $3}') else echo "It's not a tag" echo $GITHUB_REF echo ::set-output name=version::${GITHUB_REF} fi - name: Login to Registry run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }} - name: Create Buildx run: docker buildx create --name mybuilder --use - name: Build for Release # only relase, exlude pre-release if: ${{ github.event.release.prerelease == false }} run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push - name: Build for Pre-release # no update for latest tag if: ${{ github.event.release.prerelease == true }} run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push ================================================ FILE: .github/workflows/build-release-artifacts.yaml ================================================ name: Build Release Artifacts on: workflow_dispatch: ## 发布release的时候会自动构建 release: types: [published] jobs: build-artifacts: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 with: persist-credentials: false - name: Check version id: check_version run: | echo $GITHUB_REF # 如果是tag,则去掉refs/tags/前缀 if [[ $GITHUB_REF == refs/tags/* ]]; then echo "It's a tag" echo $GITHUB_REF echo $GITHUB_REF | awk -F '/' '{print $3}' echo ::set-output name=version::$(echo $GITHUB_REF | awk -F '/' '{print $3}') else echo "It's not a tag" echo $GITHUB_REF echo ::set-output name=version::${GITHUB_REF} fi - name: Make Temp Directory run: | mkdir -p /tmp/langbot_build_web cp -r . /tmp/langbot_build_web - name: Setup Node uses: actions/setup-node@v2 with: node-version: '22' - name: Build Web run: | cd /tmp/langbot_build_web/web npm install npm run build - name: Package Output run: | cp -r /tmp/langbot_build_web/web/out ./web - name: Upload Artifact uses: actions/upload-artifact@v4 with: name: langbot-${{ steps.check_version.outputs.version }}-all path: . - name: Upload To Release env: GH_TOKEN: ${{ secrets.RELEASE_UPLOAD_GITHUB_TOKEN }} run: | # 本目录下所有文件打包成zip zip -r langbot-${{ steps.check_version.outputs.version }}-all.zip . gh release upload ${{ github.event.release.tag_name }} langbot-${{ steps.check_version.outputs.version }}-all.zip ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: push: branches: - main - master - dev pull_request: types: [opened, synchronize, reopened, ready_for_review] jobs: ruff: name: Ruff Lint & Format runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install uv uses: astral-sh/setup-uv@v4 - name: Install dependencies run: uv sync --dev - name: Run ruff check run: uv run ruff check src - name: Run ruff format run: uv run ruff format src --check frontend: name: Frontend Lint runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '25' - name: Install pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Install dependencies working-directory: web run: pnpm install - name: Run lint working-directory: web run: pnpm lint ================================================ FILE: .github/workflows/publish-to-pypi.yml ================================================ name: Build and Publish to PyPI on: workflow_dispatch: release: types: [published] jobs: build-and-publish: runs-on: ubuntu-latest permissions: contents: read id-token: write # Required for trusted publishing to PyPI steps: - name: Checkout code uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '22' - name: Build frontend run: | cd web npm install -g pnpm pnpm install pnpm build mkdir -p ../src/langbot/web/out cp -r out ../src/langbot/web/ - name: Install the latest version of uv uses: astral-sh/setup-uv@v6 with: version: "latest" - name: Build package run: | uv build - name: Publish to PyPI run: | uv publish --token ${{ secrets.PYPI_TOKEN }} ================================================ FILE: .github/workflows/run-tests.yml ================================================ name: Unit Tests on: pull_request: types: [opened, ready_for_review, synchronize] paths: - 'pkg/**' - 'tests/**' - '.github/workflows/run-tests.yml' - 'pyproject.toml' - 'run_tests.sh' push: branches: - master - develop paths: - 'pkg/**' - 'tests/**' - '.github/workflows/run-tests.yml' - 'pyproject.toml' - 'run_tests.sh' jobs: test: name: Run Unit Tests runs-on: ubuntu-latest strategy: matrix: python-version: ['3.11', '3.12', '3.13'] fail-fast: false steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install uv run: | curl -LsSf https://astral.sh/uv/install.sh | sh echo "$HOME/.cargo/bin" >> $GITHUB_PATH - name: Install dependencies run: | uv sync --dev - name: Run unit tests run: | bash run_tests.sh - name: Upload coverage to Codecov if: matrix.python-version == '3.12' uses: codecov/codecov-action@v5 with: files: ./coverage.xml flags: unit-tests name: unit-tests-coverage fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Test Summary if: always() run: | echo "## Unit Tests Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Python Version: ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY ================================================ FILE: .github/workflows/test-dev-image.yaml ================================================ name: Test Dev Image on: workflow_run: workflows: ["Build Dev Image"] types: - completed branches: - master jobs: test-dev-image: runs-on: ubuntu-latest # Only run if the build workflow succeeded if: ${{ github.event.workflow_run.conclusion == 'success' }} permissions: contents: read steps: - name: Checkout uses: actions/checkout@v4 - name: Update Docker Compose to use master tag working-directory: ./docker run: | # Replace 'latest' with 'master' tag for testing the dev image sed -i 's/rockchin\/langbot:latest/rockchin\/langbot:master/g' docker-compose.yaml echo "Updated docker-compose.yaml to use master tag:" cat docker-compose.yaml - name: Start Docker Compose working-directory: ./docker run: docker compose up -d - name: Wait and Test API run: | # Function to test API endpoint test_api() { echo "Testing API endpoint..." response=$(curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" http://localhost:5300/api/v1/system/info 2>&1) curl_exit_code=$? if [ $curl_exit_code -ne 0 ]; then echo "Curl failed with exit code: $curl_exit_code" echo "Error: $response" return 1 fi http_code=$(echo "$response" | tail -n 1) response_body=$(echo "$response" | head -n -1) if [ "$http_code" = "200" ]; then echo "API is healthy! Response code: $http_code" echo "Response: $response_body" return 0 else echo "API returned non-200 response: $http_code" echo "Response body: $response_body" return 1 fi } # Wait 30 seconds before first attempt echo "Waiting 30 seconds for services to start..." sleep 30 # Try up to 3 times with 30-second intervals max_attempts=3 attempt=1 while [ $attempt -le $max_attempts ]; do echo "Attempt $attempt of $max_attempts" if test_api; then echo "Success! API is responding correctly." exit 0 fi if [ $attempt -lt $max_attempts ]; then echo "Retrying in 30 seconds..." sleep 30 fi attempt=$((attempt + 1)) done # All attempts failed echo "Failed to get healthy response after $max_attempts attempts" exit 1 - name: Show Container Logs on Failure if: failure() working-directory: ./docker run: | echo "=== Docker Compose Status ===" docker compose ps echo "" echo "=== LangBot Logs ===" docker compose logs langbot echo "" echo "=== Plugin Runtime Logs ===" docker compose logs langbot_plugin_runtime - name: Cleanup if: always() working-directory: ./docker run: docker compose down ================================================ FILE: .gitignore ================================================ /config.py .idea/ __pycache__/ database.db langbot.log /banlist.py /plugins/ !/plugins/__init__.py /revcfg.py prompts/ logs/ sensitive.json temp/ current_tag scenario/ !scenario/default-template.json override.json cookies.json data/labels/announcement_saved.json cmdpriv.json tips.py venv* bin/ .vscode /test_* venv/ hugchat.json qcapi claude.json bard.json /*yaml !.pre-commit-config.yaml !components.yaml !/docker-compose.yaml data/labels/instance_id.json .DS_Store /data botpy.log* /poc /libs/wecom_api/test.py /venv test.py /web_ui .venv/ /test plugins.bak coverage.xml .coverage src/langbot/web/ # Build artifacts /dist /build *.egg-info ================================================ FILE: .mcp.json ================================================ { "mcpServers": { "shadcn": { "command": "npx", "args": [ "shadcn@latest", "mcp" ] }, "sequential-thinking": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"], "env": {} }, "github": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" } }, "fetch": { "type": "stdio", "command": "uvx", "args": ["mcp-server-fetch"], "env": {} }, "playwright": { "type": "stdio", "command": "npx", "args": ["-y", "@playwright/mcp@latest"], "env": {} } } } ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.11.7 hooks: # Run the linter of backend. - id: ruff args: [--fix] # Run the formatter of backend. - id: ruff-format - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.1.0 hooks: - id: prettier types_or: [javascript, jsx, ts, tsx, css, scss] additional_dependencies: - prettier@3.1.0 - repo: local hooks: - id: lint-staged name: lint-staged entry: cd web && pnpm lint-staged language: system types: [javascript, jsx, ts, tsx] pass_filenames: false ================================================ FILE: AGENTS.md ================================================ # AGENTS.md This file is for guiding code agents (like Claude Code, GitHub Copilot, OpenAI Codex, etc.) to work in LangBot project. ## Project Overview LangBot is a open-source LLM native instant messaging bot development platform, aiming to provide an out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, supporting global instant messaging platforms, and providing rich API interfaces, supporting custom development. LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts: - `./src/langbot`: The main python package of the project, below are the main modules in this package: - `./pkg`: The core python package of the project backend. - `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc. - `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc. - `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc. - `./pkg/api`: The api module of the project, containing the http api controllers and services. - `./pkg/plugin`: LangBot bridge for connecting with plugin system. - `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc. - `./templates`: Templates of config files, components, etc. - `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**. - `./docker`: docker-compose deployment files. ## Backend Development We use `uv` to manage dependencies. ```bash pip install uv uv sync --dev ``` Start the backend and run the project in development mode. ```bash uv run main.py ``` Then you can access the project at `http://127.0.0.1:5300`. ## Frontend Development We use `pnpm` to manage dependencies. ```bash cd web cp .env.example .env pnpm install pnpm dev ``` Then you can access the project at `http://127.0.0.1:3000`. ## Plugin System Architecture LangBot is composed of various internal components such as Large Language Model tools, commands, messaging platform adapters, LLM requesters, and more. To meet extensibility and flexibility requirements, we have implemented a production-grade plugin system. Each plugin runs in an independent process, managed uniformly by the Plugin Runtime. It has two operating modes: `stdio` and `websocket`. When LangBot is started directly by users (not running in a container), it uses `stdio` mode, which is common for personal users or lightweight environments. When LangBot runs in a container, it uses `websocket` mode, designed specifically for production environments. Plugin Runtime automatically starts each installed plugin and interacts through stdio. In plugin development scenarios, developers can use the lbp command-line tool to start plugins and connect to the running Runtime via WebSocket for debugging. > Plugin SDK, CLI, Runtime, and entities definitions shared between LangBot and plugins are contained in the [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk) repository. ## Some Development Tips and Standards - LangBot is a global project, any comments in code should be in English, and user experience should be considered in all aspects. - Thus you should consider the i18n support in all aspects. - LangBot is widely adopted in both toC and toB scenarios, so you should consider the compatibility and security in all aspects. - If you were asked to make a commit, please follow the commit message format: - format: (): - type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc. - scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc. - subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc. - If you changed the definition of database entities, please update the migration file in `src/langbot/pkg/persistence/migrations/` and update the constants.py file in `src/langbot/pkg/utils/constants.py` with the new migration number. ## Some Principles - Keep it simple, stupid. - Entities should not be multiplied unnecessarily - 八荣八耻 以瞎猜接口为耻,以认真查询为荣。 以模糊执行为耻,以寻求确认为荣。 以臆想业务为耻,以人类确认为荣。 以创造接口为耻,以复用现有为荣。 以跳过验证为耻,以主动测试为荣。 以破坏架构为耻,以遵循规范为荣。 以假装理解为耻,以诚实无知为荣。 以盲目修改为耻,以谨慎重构为荣。 ================================================ FILE: CONTRIBUTING.md ================================================ ## 参与项目 欢迎为此项目贡献代码或其他支持,以使您的点子或众人期待的功能成为现实,助力社区成长。 ### 贡献形式 - 提交PR,解决issues中提到的bug或期待的功能 - 提交PR,实现您设想的功能(请先提出issue与项目维护者沟通) - 为本项目在其他社交平台撰写文章、制作视频等 - 为本项目的衍生项目作出贡献,或开发插件增加功能 ### 沟通语言规范 - 在 PR 和 Commit Message 中请使用全英文 - 对于中文用户,issue 中可以使用中文
## Guidelines ### Contribution - Submit PRs to solve bugs or features in the issues - Submit PRs to implement your ideas (Please create an issue first and communicate with the project maintainer) - Write articles or make videos about this project on other social platforms - Contribute to the development of derivative projects, or develop plugins to add features ### Spoken Language - Use English in PRs and Commit Messages - For English users, you can use English in issues ================================================ FILE: Dockerfile ================================================ FROM node:22-alpine AS node WORKDIR /app COPY web ./web RUN cd web && npm install && npm run build FROM python:3.12.7-slim WORKDIR /app COPY . . COPY --from=node /app/web/out ./web/out RUN apt update \ && apt install gcc -y \ && python -m pip install --no-cache-dir uv \ && uv sync \ && touch /.dockerenv CMD [ "uv", "run", "--no-sync", "main.py" ] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================

LangBot

LangBot - Production-grade IM bot made easy. | Product Hunt

Production-grade platform for building agentic IM bots.

Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.

English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) python [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) WebsiteFeaturesDocsAPICloudPlugin MarketRoadmap

--- ## What is LangBot? LangBot is an **open-source, production-grade platform** for building AI-powered instant messaging bots. It connects Large Language Models (LLMs) to any chat platform, enabling you to create intelligent agents that can converse, execute tasks, and integrate with your existing workflows. ### Key Capabilities - **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). - **Universal IM Platform Support** — One codebase for Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Production-Ready** — Access control, rate limiting, sensitive word filtering, comprehensive monitoring, and exception handling. Trusted by enterprises. - **Plugin Ecosystem** — Hundreds of plugins, event-driven architecture, component extensions, and [MCP protocol](https://modelcontextprotocol.io/) support. - **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required. - **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling. [→ Learn more about all features](https://docs.langbot.app/en/insight/features) --- ## Quick Start ### ☁️ LangBot Cloud (Recommended) **[LangBot Cloud](https://space.langbot.app/cloud)** — Zero deployment, ready to use. ### One-Line Launch ```bash uvx langbot ``` > Requires [uv](https://docs.astral.sh/uv/getting-started/installation/). Visit http://localhost:5300 — done. ### Docker Compose ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker docker compose up -d ``` ### One-Click Cloud Deploy [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) **More options:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt) · [Kubernetes](./docker/README_K8S.md) --- ## Supported Platforms | Platform | Status | Notes | |----------|--------|-------| | Discord | ✅ | | | Telegram | ✅ | | | Slack | ✅ | | | LINE | ✅ | | | QQ | ✅ | Personal & Official API | | WeCom | ✅ | Enterprise WeChat, External CS, AI Bot | | WeChat | ✅ | Personal & Official Account | | Lark | ✅ | | | DingTalk | ✅ | | | KOOK | ✅ | | | Satori | ✅ | | --- ## Supported LLMs & Integrations | Provider | Type | Status | |----------|------|--------| | [OpenAI](https://platform.openai.com/) | LLM | ✅ | | [Anthropic](https://www.anthropic.com/) | LLM | ✅ | | [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | | [xAI](https://x.ai/) | LLM | ✅ | | [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | | [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | | [Ollama](https://ollama.com/) | Local LLM | ✅ | | [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ | | [Dify](https://dify.ai) | LLMOps | ✅ | | [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ | | [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ | | [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ | | [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ | | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ | | [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ | | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ | | [接口 AI](https://jiekou.ai/) | Gateway | ✅ | | [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ | [→ View all integrations](https://docs.langbot.app/en/insight/features) --- ## Why LangBot? | Use Case | How LangBot Helps | |----------|-------------------| | **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base | | **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes | | **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction | | **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard | --- ## Live Demo **Try it now:** https://demo.langbot.dev/ - Email: `demo@langbot.app` - Password: `langbot123456` *Note: Public demo environment. Do not enter sensitive information.* --- ## Community [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) - [Discord Community](https://discord.gg/wdNEHETs87) --- ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) --- ## Contributors Thanks to all [contributors](https://github.com/langbot-app/LangBot/graphs/contributors) who have helped make LangBot better: ================================================ FILE: README_CN.md ================================================

LangBot

Featured|HelloGitHub

生产级 AI 即时通信机器人开发平台。

快速构建、调试和部署 AI 机器人到微信、QQ、飞书、Slack、Discord、Telegram 等平台。

[English](README.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/DxZZcNxM1W) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) python [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) 官网特性文档APICloud插件市场路线图

--- ## 什么是 LangBot? LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。 ### 核心能力 - **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。 - **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。 - **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。 - **插件生态** — 数百个插件,事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。 - **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。 - **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。 [→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html) --- ## 快速开始 ### ☁️ LangBot Cloud(推荐) **[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,开箱即用。 ### 一键启动 ```bash uvx langbot ``` > 需要安装 [uv](https://docs.astral.sh/uv/getting-started/installation/)。访问 http://localhost:5300 即可使用。 ### Docker Compose ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker docker compose up -d ``` ### 一键云部署 [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) **更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [宝塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md) --- ## 支持的平台 | 平台 | 状态 | 备注 | |------|------|------| | QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) | | 微信 | ✅ | 个人微信、微信公众号 | | 企业微信 | ✅ | 应用消息、对外客服、智能机器人 | | 飞书 | ✅ | | | 钉钉 | ✅ | | | Discord | ✅ | | | Telegram | ✅ | | | Slack | ✅ | | | LINE | ✅ | | | KOOK | ✅ | | --- ## 支持的大模型与集成 | 提供商 | 类型 | 状态 | |--------|------|------| | [OpenAI](https://platform.openai.com/) | LLM | ✅ | | [Anthropic](https://www.anthropic.com/) | LLM | ✅ | | [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | | [xAI](https://x.ai/) | LLM | ✅ | | [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | | [智谱AI](https://open.bigmodel.cn/) | LLM | ✅ | | [Ollama](https://ollama.com/) | 本地 LLM | ✅ | | [LM Studio](https://lmstudio.ai/) | 本地 LLM | ✅ | | [Dify](https://dify.ai) | LLMOps | ✅ | | [MCP](https://modelcontextprotocol.io/) | 协议 | ✅ | | [SiliconFlow](https://siliconflow.cn/) | 聚合平台 | ✅ | | [阿里云百炼](https://bailian.console.aliyun.com/) | 聚合平台 | ✅ | | [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 聚合平台 | ✅ | | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 聚合平台 | ✅ | | [GiteeAI](https://ai.gitee.com/) | 聚合平台 | ✅ | | [胜算云](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 平台 | ✅ | | [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ | | [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ | | [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ | | [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ | | [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ | [→ 查看完整集成列表](https://docs.langbot.app/zh/insight/features.html) ### TTS(语音合成) | 平台/模型 | 备注 | |-----------|------| | [FishAudio](https://fish.audio/zh-CN/discovery/) | [插件](https://github.com/the-lazy-me/NewChatVoice) | | [海豚 AI](https://www.ttson.cn/?source=thelazy) | [插件](https://github.com/the-lazy-me/NewChatVoice) | | [AzureTTS](https://portal.azure.com/) | [插件](https://github.com/Ingnaryk/LangBot_AzureTTS) | ### 文生图 | 平台/模型 | 备注 | |-----------|------| | 阿里云百炼 | [插件](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) | --- ## 为什么选择 LangBot? | 使用场景 | LangBot 如何帮助 | |----------|------------------| | **客户服务** | 将 AI Agent 部署到微信/企微/钉钉/飞书,基于知识库自动回答用户问题 | | **内部工具** | 将 n8n/Dify 工作流接入企微/钉钉,实现业务流程自动化 | | **社群运营** | 在 QQ/Discord 群中使用 AI 驱动的内容审核与智能互动 | | **多平台触达** | 一个机器人,覆盖所有平台。通过统一面板集中管理 | --- ## 在线演示 **立即体验:** https://demo.langbot.dev/ - 邮箱:`demo@langbot.app` - 密码:`langbot123456` *注意:公开演示环境,请不要在其中填入任何敏感信息。* --- ## 社区 [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) [![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/DxZZcNxM1W) - [Discord 社区](https://discord.gg/wdNEHETs87) - [QQ 社区群](https://qm.qq.com/q/DxZZcNxM1W) --- ## Star 趋势 [![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) --- ## 贡献者 感谢所有[贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)对 LangBot 的帮助: ================================================ FILE: README_ES.md ================================================

LangBot

LangBot - Production-grade IM bot made easy. | Product Hunt

Plataforma de grado de producción para construir bots de mensajería instantánea con agentes de IA.

Construya, depure y despliegue bots de IA rápidamente en Slack, Discord, Telegram, WeChat y más.

[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / Español / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) python [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) InicioCaracterísticasDocumentaciónAPIMercado de PluginsHoja de Ruta

--- ## ¿Qué es LangBot? LangBot es una **plataforma de código abierto y grado de producción** para construir bots de mensajería instantánea impulsados por IA. Conecta modelos de lenguaje de gran escala (LLMs) con cualquier plataforma de chat, permitiéndole crear agentes inteligentes que pueden conversar, ejecutar tareas e integrarse con sus flujos de trabajo existentes. ### Capacidades Clave - **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). - **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas. - **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/). - **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML. - **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones. [→ Conocer más sobre todas las funcionalidades](https://docs.langbot.app/en/insight/features.html) --- ## Inicio Rápido ### ☁️ LangBot Cloud (Recomendado) **[LangBot Cloud](https://space.langbot.app/cloud)** — Sin despliegue, listo para usar. ### Lanzamiento en una línea ```bash uvx langbot ``` > Requiere [uv](https://docs.astral.sh/uv/getting-started/installation/). Visite http://localhost:5300 — listo. ### Docker Compose ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker docker compose up -d ``` ### Despliegue en la Nube con un Clic [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) **Más opciones:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md) --- ## Plataformas Soportadas | Plataforma | Estado | Notas | |----------|--------|-------| | Discord | ✅ | | | Telegram | ✅ | | | Slack | ✅ | | | LINE | ✅ | | | QQ | ✅ | Personal y API Oficial | | WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot | | WeChat | ✅ | Personal y Cuenta Oficial | | Lark | ✅ | | | DingTalk | ✅ | | | KOOK | ✅ | | | Satori | ✅ | | --- ## LLMs e Integraciones Soportadas | Proveedor | Tipo | Estado | |----------|------|--------| | [OpenAI](https://platform.openai.com/) | LLM | ✅ | | [Anthropic](https://www.anthropic.com/) | LLM | ✅ | | [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | | [xAI](https://x.ai/) | LLM | ✅ | | [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | | [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | | [Ollama](https://ollama.com/) | LLM Local | ✅ | | [LM Studio](https://lmstudio.ai/) | LLM Local | ✅ | | [Dify](https://dify.ai) | LLMOps | ✅ | | [MCP](https://modelcontextprotocol.io/) | Protocolo | ✅ | | [SiliconFlow](https://siliconflow.cn/) | Pasarela | ✅ | | [Aliyun Bailian](https://bailian.console.aliyun.com/) | Pasarela | ✅ | | [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Pasarela | ✅ | | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Pasarela | ✅ | | [GiteeAI](https://ai.gitee.com/) | Pasarela | ✅ | | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plataforma GPU | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plataforma GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ | | [接口 AI](https://jiekou.ai/) | Pasarela | ✅ | | [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ | [→ Ver todas las integraciones](https://docs.langbot.app/en/insight/features.html) --- ## ¿Por qué LangBot? | Caso de Uso | Cómo Ayuda LangBot | |----------|-------------------| | **Atención al cliente** | Despliegue agentes de IA en Slack/Discord/Telegram que respondan preguntas usando su base de conocimientos | | **Herramientas internas** | Conecte flujos de trabajo de n8n/Dify a WeCom/DingTalk para procesos empresariales automatizados | | **Gestión de comunidades** | Modere grupos de QQ/Discord con filtrado de contenido e interacción impulsados por IA | | **Presencia multiplataforma** | Un solo bot, todas las plataformas. Gestione desde un único panel de control | --- ## Demo en Vivo **Pruébelo ahora:** https://demo.langbot.dev/ - Correo electrónico: `demo@langbot.app` - Contraseña: `langbot123456` *Nota: Entorno de demostración público. No ingrese información confidencial.* --- ## Comunidad [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) - [Comunidad de Discord](https://discord.gg/wdNEHETs87) --- ## Historial de Stars [![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) --- ## Colaboradores Gracias a todos los [colaboradores](https://github.com/langbot-app/LangBot/graphs/contributors) que han ayudado a mejorar LangBot: ================================================ FILE: README_FR.md ================================================

LangBot

LangBot - Production-grade IM bot made easy. | Product Hunt

Plateforme de niveau production pour construire des bots de messagerie instantanée avec agents IA.

Créez, déboguez et déployez rapidement des bots IA sur Slack, Discord, Telegram, WeChat et plus.

[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / Français / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) python [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) AccueilFonctionnalitésDocumentationAPIMarché des PluginsFeuille de Route

--- ## Qu'est-ce que LangBot ? LangBot est une **plateforme open-source de niveau production** pour créer des bots de messagerie instantanée alimentés par l'IA. Elle connecte les grands modèles de langage (LLMs) à n'importe quelle plateforme de chat, vous permettant de créer des agents intelligents capables de converser, d'exécuter des tâches et de s'intégrer à vos workflows existants. ### Capacités Clés - **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). - **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises. - **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/). - **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise. - **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions. [→ En savoir plus sur toutes les fonctionnalités](https://docs.langbot.app/en/insight/features.html) --- ## Démarrage Rapide ### ☁️ LangBot Cloud (Recommandé) **[LangBot Cloud](https://space.langbot.app/cloud)** — Sans déploiement, prêt à utiliser. ### Lancement en une ligne ```bash uvx langbot ``` > Nécessite [uv](https://docs.astral.sh/uv/getting-started/installation/). Visitez http://localhost:5300 — c'est prêt. ### Docker Compose ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker docker compose up -d ``` ### Déploiement Cloud en un Clic [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) **Plus d'options :** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manuel](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md) --- ## Plateformes Supportées | Plateforme | Statut | Notes | |----------|--------|-------| | Discord | ✅ | | | Telegram | ✅ | | | Slack | ✅ | | | LINE | ✅ | | | QQ | ✅ | Personnel & API Officielle | | WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot | | WeChat | ✅ | Personnel & Compte Officiel | | Lark | ✅ | | | DingTalk | ✅ | | | KOOK | ✅ | | | Satori | ✅ | | --- ## LLMs et Intégrations Supportés | Fournisseur | Type | Statut | |----------|------|--------| | [OpenAI](https://platform.openai.com/) | LLM | ✅ | | [Anthropic](https://www.anthropic.com/) | LLM | ✅ | | [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | | [xAI](https://x.ai/) | LLM | ✅ | | [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | | [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | | [Ollama](https://ollama.com/) | LLM Local | ✅ | | [LM Studio](https://lmstudio.ai/) | LLM Local | ✅ | | [Dify](https://dify.ai) | LLMOps | ✅ | | [MCP](https://modelcontextprotocol.io/) | Protocole | ✅ | | [SiliconFlow](https://siliconflow.cn/) | Passerelle | ✅ | | [Aliyun Bailian](https://bailian.console.aliyun.com/) | Passerelle | ✅ | | [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Passerelle | ✅ | | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Passerelle | ✅ | | [GiteeAI](https://ai.gitee.com/) | Passerelle | ✅ | | [接口 AI](https://jiekou.ai/) | Passerelle | ✅ | | [302.AI](https://share.302.ai/SuTG99) | Passerelle | ✅ | | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ | [→ Voir toutes les intégrations](https://docs.langbot.app/en/insight/features.html) --- ## Pourquoi LangBot ? | Cas d'Usage | Comment LangBot Aide | |----------|-------------------| | **Support Client** | Déployez des agents IA sur Slack/Discord/Telegram qui répondent aux questions en utilisant votre base de connaissances | | **Outils Internes** | Connectez les workflows n8n/Dify à WeCom/DingTalk pour automatiser vos processus métier | | **Gestion de Communauté** | Modérez les groupes QQ/Discord avec un filtrage de contenu et des interactions alimentés par l'IA | | **Présence Multi-plateforme** | Un seul bot, toutes les plateformes. Gérez tout depuis un tableau de bord unique | --- ## Démo en Ligne **Essayez maintenant :** https://demo.langbot.dev/ - Email : `demo@langbot.app` - Mot de passe : `langbot123456` *Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.* --- ## Communauté [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) - [Communauté Discord](https://discord.gg/wdNEHETs87) --- ## Historique des Stars [![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) --- ## Contributeurs Merci à tous les [contributeurs](https://github.com/langbot-app/LangBot/graphs/contributors) qui ont aidé à améliorer LangBot : ================================================ FILE: README_JP.md ================================================

LangBot

LangBot - Production-grade IM bot made easy. | Product Hunt

AIエージェント搭載IMボットを構築するための本番グレードプラットフォーム。

Slack、Discord、Telegram、WeChat などに AI ボットを素早く構築、デバッグ、デプロイ。

[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / 日本語 / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) python [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) ホーム機能ドキュメントAPIプラグインマーケットロードマップ

--- ## LangBot とは? LangBot は、AI搭載のインスタントメッセージングボットを構築するための**オープンソースの本番グレードプラットフォーム**です。大規模言語モデル(LLM)をあらゆるチャットプラットフォームに接続し、会話、タスク実行、既存のワークフローとの統合が可能なインテリジェントエージェントを作成できます。 ### 主な機能 - **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) と深く統合。 - **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。 - **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。 - **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。 - **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。 - **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。 [→ すべての機能について詳しく見る](https://docs.langbot.app/ja/insight/features.html) --- ## クイックスタート ### ☁️ LangBot Cloud(推奨) **[LangBot Cloud](https://space.langbot.app/cloud)** — デプロイ不要、すぐに使えます。 ### ワンライン起動 ```bash uvx langbot ``` > [uv](https://docs.astral.sh/uv/getting-started/installation/) が必要です。http://localhost:5300 にアクセスして完了。 ### Docker Compose ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker docker compose up -d ``` ### ワンクリッククラウドデプロイ [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) **その他:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [手動デプロイ](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md) --- ## 対応プラットフォーム | プラットフォーム | ステータス | 備考 | |----------|--------|-------| | Discord | ✅ | | | Telegram | ✅ | | | Slack | ✅ | | | LINE | ✅ | | | QQ | ✅ | 個人 & 公式API | | WeCom | ✅ | 企業WeChat、外部CS、AIボット | | WeChat | ✅ | 個人 & 公式アカウント | | Lark | ✅ | | | DingTalk | ✅ | | | KOOK | ✅ | | | Satori | ✅ | | --- ## 対応LLMと統合 | プロバイダー | タイプ | ステータス | |----------|------|--------| | [OpenAI](https://platform.openai.com/) | LLM | ✅ | | [Anthropic](https://www.anthropic.com/) | LLM | ✅ | | [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | | [xAI](https://x.ai/) | LLM | ✅ | | [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | | [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | | [Ollama](https://ollama.com/) | ローカルLLM | ✅ | | [LM Studio](https://lmstudio.ai/) | ローカルLLM | ✅ | | [Dify](https://dify.ai) | LLMOps | ✅ | | [MCP](https://modelcontextprotocol.io/) | プロトコル | ✅ | | [SiliconFlow](https://siliconflow.cn/) | ゲートウェイ | ✅ | | [Aliyun Bailian](https://bailian.console.aliyun.com/) | ゲートウェイ | ✅ | | [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ゲートウェイ | ✅ | | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ゲートウェイ | ✅ | | [GiteeAI](https://ai.gitee.com/) | ゲートウェイ | ✅ | | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPUプラットフォーム | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPUプラットフォーム | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ | | [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ | | [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ | [→ すべての統合を表示](https://docs.langbot.app/en/insight/features.html) --- ## なぜ LangBot? | ユースケース | LangBot の活用方法 | |----------|-------------------| | **カスタマーサポート** | ナレッジベースを活用して質問に回答するAIエージェントをSlack/Discord/Telegramにデプロイ | | **社内ツール** | n8n/Difyのワークフローを WeCom/DingTalk に接続し、業務プロセスを自動化 | | **コミュニティ管理** | AI搭載のコンテンツフィルタリングとインタラクションでQQ/Discordグループをモデレーション | | **マルチプラットフォーム展開** | 1つのボットで全プラットフォームに対応。単一のダッシュボードから管理 | --- ## ライブデモ **今すぐ試す:** https://demo.langbot.dev/ - メール: `demo@langbot.app` - パスワード: `langbot123456` *注意: 公開デモ環境です。機密情報を入力しないでください。* --- ## コミュニティ [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) - [Discord コミュニティ](https://discord.gg/wdNEHETs87) --- ## Star 推移 [![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) --- ## コントリビューター LangBot をより良くするために貢献してくださったすべての[コントリビューター](https://github.com/langbot-app/LangBot/graphs/contributors)に感謝します: ================================================ FILE: README_KO.md ================================================

LangBot

LangBot - Production-grade IM bot made easy. | Product Hunt

AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.

Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.

[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / 한국어 / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) python [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) 기능문서API플러그인 마켓로드맵

--- ## LangBot이란? LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈소스 프로덕션 등급 플랫폼**입니다. 대규모 언어 모델(LLM)을 모든 채팅 플랫폼에 연결하여 대화, 작업 실행, 기존 워크플로우와의 통합이 가능한 지능형 에이전트를 만들 수 있습니다. ### 핵심 기능 - **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) 심층 통합. - **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원. - **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨. - **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원. - **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요. - **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리. [→ 모든 기능 자세히 보기](https://docs.langbot.app/en/insight/features.html) --- ## 빠른 시작 ### ☁️ LangBot Cloud (추천) **[LangBot Cloud](https://space.langbot.app/cloud)** — 배포 없이 바로 사용. ### 원라인 실행 ```bash uvx langbot ``` > [uv](https://docs.astral.sh/uv/getting-started/installation/) 설치 필요. http://localhost:5300 방문 — 완료. ### Docker Compose ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker docker compose up -d ``` ### 원클릭 클라우드 배포 [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) **더 많은 옵션:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [수동 배포](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md) --- ## 지원 플랫폼 | 플랫폼 | 상태 | 비고 | |--------|------|------| | Discord | ✅ | | | Telegram | ✅ | | | Slack | ✅ | | | LINE | ✅ | | | QQ | ✅ | 개인 및 공식 API | | WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot | | WeChat | ✅ | 개인 및 공식 계정 | | Lark | ✅ | | | DingTalk | ✅ | | | KOOK | ✅ | | | Satori | ✅ | | --- ## 지원 LLM 및 통합 | 제공자 | 유형 | 상태 | |--------|------|------| | [OpenAI](https://platform.openai.com/) | LLM | ✅ | | [Anthropic](https://www.anthropic.com/) | LLM | ✅ | | [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | | [xAI](https://x.ai/) | LLM | ✅ | | [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | | [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | | [Ollama](https://ollama.com/) | 로컬 LLM | ✅ | | [LM Studio](https://lmstudio.ai/) | 로컬 LLM | ✅ | | [Dify](https://dify.ai) | LLMOps | ✅ | | [MCP](https://modelcontextprotocol.io/) | 프로토콜 | ✅ | | [SiliconFlow](https://siliconflow.cn/) | 게이트웨이 | ✅ | | [Aliyun Bailian](https://bailian.console.aliyun.com/) | 게이트웨이 | ✅ | | [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 게이트웨이 | ✅ | | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 게이트웨이 | ✅ | | [GiteeAI](https://ai.gitee.com/) | 게이트웨이 | ✅ | | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 플랫폼 | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 플랫폼 | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ | | [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ | | [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ | [→ 모든 통합 보기](https://docs.langbot.app/en/insight/features.html) --- ## 왜 LangBot인가? | 사용 사례 | LangBot 활용 방법 | |-----------|-------------------| | **고객 지원** | 지식 베이스를 활용하여 질문에 답변하는 AI 에이전트를 Slack/Discord/Telegram에 배포 | | **내부 도구** | n8n/Dify 워크플로우를 WeCom/DingTalk에 연결하여 비즈니스 프로세스 자동화 | | **커뮤니티 관리** | AI 기반 콘텐츠 필터링 및 상호작용으로 QQ/Discord 그룹 관리 | | **멀티 플랫폼** | 하나의 봇으로 모든 플랫폼 지원. 단일 대시보드에서 관리 | --- ## 라이브 데모 **지금 체험:** https://demo.langbot.dev/ - 이메일: `demo@langbot.app` - 비밀번호: `langbot123456` *참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.* --- ## 커뮤니티 [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) - [Discord 커뮤니티](https://discord.gg/wdNEHETs87) --- ## Star 추이 [![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) --- ## 기여자 LangBot을 더 나은 프로젝트로 만들어 주신 모든 [기여자](https://github.com/langbot-app/LangBot/graphs/contributors)분들께 감사드립니다: ================================================ FILE: README_RU.md ================================================

LangBot

LangBot - Production-grade IM bot made easy. | Product Hunt

Платформа производственного уровня для создания агентных IM-ботов.

Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.

[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / Русский / [Tiếng Việt](README_VI.md) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) python [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) ГлавнаяВозможностиДокументацияAPIМагазин плагиновДорожная карта

--- ## Что такое LangBot? LangBot — это **платформа с открытым исходным кодом производственного уровня** для создания ИИ-ботов в мессенджерах. Она связывает большие языковые модели (LLM) с любой чат-платформой, позволяя создавать интеллектуальных агентов, которые могут вести диалоги, выполнять задачи и интегрироваться с вашими существующими рабочими процессами. ### Ключевые возможности - **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). - **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде. - **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/). - **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется. - **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений. [→ Подробнее обо всех возможностях](https://docs.langbot.app/en/insight/features.html) --- ## Быстрый старт ### ☁️ LangBot Cloud (Рекомендуется) **[LangBot Cloud](https://space.langbot.app/cloud)** — Без развёртывания, готово к использованию. ### Запуск одной командой ```bash uvx langbot ``` > Требуется [uv](https://docs.astral.sh/uv/getting-started/installation/). Откройте http://localhost:5300 — готово. ### Docker Compose ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker docker compose up -d ``` ### Облачное развертывание одним кликом [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) **Другие варианты:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Ручная установка](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md) --- ## Поддерживаемые платформы | Платформа | Статус | Примечания | |-----------|--------|------------| | Discord | ✅ | | | Telegram | ✅ | | | Slack | ✅ | | | LINE | ✅ | | | QQ | ✅ | Личный и официальный API | | WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот | | WeChat | ✅ | Личный и официальный аккаунт | | Lark | ✅ | | | DingTalk | ✅ | | | KOOK | ✅ | | | Satori | ✅ | | --- ## Поддерживаемые LLM и интеграции | Провайдер | Тип | Статус | |-----------|-----|--------| | [OpenAI](https://platform.openai.com/) | LLM | ✅ | | [Anthropic](https://www.anthropic.com/) | LLM | ✅ | | [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | | [xAI](https://x.ai/) | LLM | ✅ | | [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | | [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | | [Ollama](https://ollama.com/) | Локальный LLM | ✅ | | [LM Studio](https://lmstudio.ai/) | Локальный LLM | ✅ | | [Dify](https://dify.ai) | LLMOps | ✅ | | [MCP](https://modelcontextprotocol.io/) | Протокол | ✅ | | [SiliconFlow](https://siliconflow.cn/) | Шлюз | ✅ | | [Aliyun Bailian](https://bailian.console.aliyun.com/) | Шлюз | ✅ | | [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Шлюз | ✅ | | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Шлюз | ✅ | | [GiteeAI](https://ai.gitee.com/) | Шлюз | ✅ | | [302.AI](https://share.302.ai/SuTG99) | Шлюз | ✅ | | [接口 AI](https://jiekou.ai/) | Шлюз | ✅ | | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ | [→ Смотреть все интеграции](https://docs.langbot.app/en/insight/features.html) --- ## Почему LangBot? | Сценарий использования | Как помогает LangBot | |------------------------|----------------------| | **Поддержка клиентов** | Разверните ИИ-агентов в Slack/Discord/Telegram, которые отвечают на вопросы, используя вашу базу знаний | | **Внутренние инструменты** | Подключите рабочие процессы n8n/Dify к WeCom/DingTalk для автоматизации бизнес-процессов | | **Управление сообществом** | Модерируйте группы QQ/Discord с помощью ИИ-фильтрации контента и взаимодействия | | **Мультиплатформенное присутствие** | Один бот — все платформы. Управляйте из единой панели | --- ## Демо **Попробуйте прямо сейчас:** https://demo.langbot.dev/ - Email: `demo@langbot.app` - Пароль: `langbot123456` *Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.* --- ## Сообщество [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) - [Сообщество Discord](https://discord.gg/wdNEHETs87) --- ## История Stars [![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) --- ## Участники Спасибо всем [участникам](https://github.com/langbot-app/LangBot/graphs/contributors), которые помогли сделать LangBot лучше: ================================================ FILE: README_TW.md ================================================

LangBot

Featured|HelloGitHub

生產級 AI 即時通訊機器人開發平台。

快速建構、除錯和部署 AI 機器人到微信、QQ、飛書、Slack、Discord、Telegram 等平台。

[English](README.md) / [简体中文](README_CN.md) / 繁體中文 / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) python [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) 官網特性文件API外掛市場路線圖

--- ## 什麼是 LangBot? LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時通訊機器人。它將大語言模型(LLM)連接到各種聊天平台,幫助你創建能夠對話、執行任務、並整合到現有工作流程中的智能 Agent。 ### 核心能力 - **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。 - **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。 - **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。 - **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。 - **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。 - **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。 [→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html) --- ## 快速開始 ### ☁️ LangBot Cloud(推薦) **[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,開箱即用。 ### 一鍵啟動 ```bash uvx langbot ``` > 需要安裝 [uv](https://docs.astral.sh/uv/getting-started/installation/)。訪問 http://localhost:5300 即可使用。 ### Docker Compose ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker docker compose up -d ``` ### 一鍵雲端部署 [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) **更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手動部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [寶塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md) --- ## 支援的平台 | 平台 | 狀態 | 備註 | |------|------|------| | QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) | | 微信 | ✅ | 個人微信、微信公眾號 | | 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 | | 飛書 | ✅ | | | 釘釘 | ✅ | | | Discord | ✅ | | | Telegram | ✅ | | | Slack | ✅ | | | LINE | ✅ | | | KOOK | ✅ | | | Satori | ✅ | | --- ## 支援的大模型與整合 | 提供商 | 類型 | 狀態 | |--------|------|------| | [OpenAI](https://platform.openai.com/) | LLM | ✅ | | [Anthropic](https://www.anthropic.com/) | LLM | ✅ | | [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | | [xAI](https://x.ai/) | LLM | ✅ | | [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | | [智譜AI](https://open.bigmodel.cn/) | LLM | ✅ | | [Ollama](https://ollama.com/) | 本地 LLM | ✅ | | [LM Studio](https://lmstudio.ai/) | 本地 LLM | ✅ | | [Dify](https://dify.ai) | LLMOps | ✅ | | [MCP](https://modelcontextprotocol.io/) | 協議 | ✅ | | [SiliconFlow](https://siliconflow.cn/) | 聚合平台 | ✅ | | [阿里雲百煉](https://bailian.console.aliyun.com/) | 聚合平台 | ✅ | | [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 聚合平台 | ✅ | | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 聚合平台 | ✅ | | [GiteeAI](https://ai.gitee.com/) | 聚合平台 | ✅ | | [勝算雲](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 平台 | ✅ | | [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ | | [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ | | [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ | ### TTS(語音合成) | 平台/模型 | 備註 | |-----------|------| | [FishAudio](https://fish.audio/zh-CN/discovery/) | [外掛](https://github.com/the-lazy-me/NewChatVoice) | | [海豚 AI](https://www.ttson.cn/?source=thelazy) | [外掛](https://github.com/the-lazy-me/NewChatVoice) | | [AzureTTS](https://portal.azure.com/) | [外掛](https://github.com/Ingnaryk/LangBot_AzureTTS) | ### 文生圖 | 平台/模型 | 備註 | |-----------|------| | 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) | [→ 查看完整整合列表](https://docs.langbot.app/zh/insight/features.html) --- ## 為什麼選擇 LangBot? | 使用場景 | LangBot 如何幫助 | |----------|------------------| | **客戶服務** | 將 AI Agent 部署到微信/企微/釘釘/飛書,基於知識庫自動回答使用者問題 | | **內部工具** | 將 n8n/Dify 工作流接入企微/釘釘,實現業務流程自動化 | | **社群運營** | 在 QQ/Discord 群中使用 AI 驅動的內容審核與智能互動 | | **多平台觸達** | 一個機器人,覆蓋所有平台。透過統一面板集中管理 | --- ## 線上演示 **立即體驗:** https://demo.langbot.dev/ - 信箱:`demo@langbot.app` - 密碼:`langbot123456` *注意:公開演示環境,請不要在其中填入任何敏感資訊。* --- ## 社群 [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) [![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum) - [Discord 社群](https://discord.gg/wdNEHETs87) - [QQ 社群群](https://qm.qq.com/q/JLi38whHum) --- ## Star 趨勢 [![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) --- ## 貢獻者 感謝所有[貢獻者](https://github.com/langbot-app/LangBot/graphs/contributors)對 LangBot 的幫助: ================================================ FILE: README_VI.md ================================================

LangBot

LangBot - Production-grade IM bot made easy. | Product Hunt

Nền tảng cấp sản xuất để xây dựng bot IM với AI agent.

Xây dựng, gỡ lỗi và triển khai bot AI nhanh chóng trên Slack, Discord, Telegram, WeChat và nhiều nền tảng khác.

[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / Tiếng Việt [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) python [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Trang chủTính năngTài liệuAPIChợ PluginLộ trình

--- ## LangBot là gì? LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để xây dựng bot nhắn tin tức thời được hỗ trợ bởi AI. Nó kết nối các Mô hình Ngôn ngữ Lớn (LLM) với bất kỳ nền tảng chat nào, cho phép bạn tạo các agent thông minh có thể trò chuyện, thực hiện tác vụ và tích hợp với quy trình làm việc hiện có của bạn. ### Khả năng chính - **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). - **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng. - **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/). - **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML. - **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ. [→ Tìm hiểu thêm về tất cả tính năng](https://docs.langbot.app/en/insight/features.html) --- ## Bắt đầu nhanh ### ☁️ LangBot Cloud (Khuyên dùng) **[LangBot Cloud](https://space.langbot.app/cloud)** — Không cần triển khai, sẵn sàng sử dụng. ### Khởi chạy một dòng ```bash uvx langbot ``` > Yêu cầu [uv](https://docs.astral.sh/uv/getting-started/installation/). Truy cập http://localhost:5300 — xong. ### Docker Compose ```bash git clone https://github.com/langbot-app/LangBot cd LangBot/docker docker compose up -d ``` ### Triển khai đám mây một cú nhấp [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) **Thêm tùy chọn:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Thủ công](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md) --- ## Nền tảng được hỗ trợ | Nền tảng | Trạng thái | Ghi chú | |----------|--------|-------| | Discord | ✅ | | | Telegram | ✅ | | | Slack | ✅ | | | LINE | ✅ | | | QQ | ✅ | Cá nhân & API chính thức | | WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot | | WeChat | ✅ | Cá nhân & Tài khoản công khai | | Lark | ✅ | | | DingTalk | ✅ | | | KOOK | ✅ | | | Satori | ✅ | | --- ## LLM và tích hợp được hỗ trợ | Nhà cung cấp | Loại | Trạng thái | |----------|------|--------| | [OpenAI](https://platform.openai.com/) | LLM | ✅ | | [Anthropic](https://www.anthropic.com/) | LLM | ✅ | | [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | | [xAI](https://x.ai/) | LLM | ✅ | | [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | | [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | | [Ollama](https://ollama.com/) | LLM cục bộ | ✅ | | [LM Studio](https://lmstudio.ai/) | LLM cục bộ | ✅ | | [Dify](https://dify.ai) | LLMOps | ✅ | | [MCP](https://modelcontextprotocol.io/) | Giao thức | ✅ | | [SiliconFlow](https://siliconflow.cn/) | Cổng | ✅ | | [Aliyun Bailian](https://bailian.console.aliyun.com/) | Cổng | ✅ | | [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Cổng | ✅ | | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Cổng | ✅ | | [GiteeAI](https://ai.gitee.com/) | Cổng | ✅ | | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Nền tảng GPU | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Nền tảng GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ | | [接口 AI](https://jiekou.ai/) | Cổng | ✅ | | [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ | [→ Xem tất cả tích hợp](https://docs.langbot.app/en/insight/features.html) --- ## Tại sao chọn LangBot? | Trường hợp sử dụng | LangBot giúp như thế nào | |----------|-------------------| | **Hỗ trợ khách hàng** | Triển khai agent AI trên Slack/Discord/Telegram để trả lời câu hỏi bằng cơ sở kiến thức của bạn | | **Công cụ nội bộ** | Kết nối quy trình n8n/Dify với WeCom/DingTalk để tự động hóa quy trình kinh doanh | | **Quản lý cộng đồng** | Quản lý nhóm QQ/Discord với tính năng lọc nội dung và tương tác được hỗ trợ bởi AI | | **Đa nền tảng** | Một bot, tất cả nền tảng. Quản lý từ một bảng điều khiển duy nhất | --- ## Demo trực tuyến **Thử ngay:** https://demo.langbot.dev/ - Email: `demo@langbot.app` - Mật khẩu: `langbot123456` *Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.* --- ## Cộng đồng [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) - [Cộng đồng Discord](https://discord.gg/wdNEHETs87) --- ## Lịch sử Star [![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) --- ## Người đóng góp Cảm ơn tất cả [người đóng góp](https://github.com/langbot-app/LangBot/graphs/contributors) đã giúp LangBot trở nên tốt hơn: ================================================ FILE: codecov.yml ================================================ coverage: status: project: off patch: off ================================================ FILE: docker/README_K8S.md ================================================ # LangBot Kubernetes 部署指南 / Kubernetes Deployment Guide [简体中文](#简体中文) | [English](#english) --- ## 简体中文 ### 概述 本指南提供了在 Kubernetes 集群中部署 LangBot 的完整步骤。Kubernetes 部署配置基于 `docker-compose.yaml`,适用于生产环境的容器化部署。 ### 前置要求 - Kubernetes 集群(版本 1.19+) - `kubectl` 命令行工具已配置并可访问集群 - 集群中有可用的存储类(StorageClass)用于持久化存储(可选但推荐) - 至少 2 vCPU 和 4GB RAM 的可用资源 ### 架构说明 Kubernetes 部署包含以下组件: 1. **langbot**: 主应用服务 - 提供 Web UI(端口 5300) - 处理平台 webhook(端口 2280-2290) - 数据持久化卷 2. **langbot-plugin-runtime**: 插件运行时服务 - WebSocket 通信(端口 5400) - 插件数据持久化卷 3. **持久化存储**: - `langbot-data`: LangBot 主数据 - `langbot-plugins`: 插件文件 - `langbot-plugin-runtime-data`: 插件运行时数据 ### 快速开始 #### 1. 下载部署文件 ```bash # 克隆仓库 git clone https://github.com/langbot-app/LangBot cd LangBot/docker # 或直接下载 kubernetes.yaml wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml ``` #### 2. 部署到 Kubernetes ```bash # 应用所有配置 kubectl apply -f kubernetes.yaml # 检查部署状态 kubectl get all -n langbot # 查看 Pod 日志 kubectl logs -n langbot -l app=langbot -f ``` #### 3. 访问 LangBot 默认情况下,LangBot 服务使用 ClusterIP 类型,只能在集群内部访问。您可以选择以下方式之一来访问: **选项 A: 端口转发(推荐用于测试)** ```bash kubectl port-forward -n langbot svc/langbot 5300:5300 ``` 然后访问 http://localhost:5300 **选项 B: NodePort(适用于开发环境)** 编辑 `kubernetes.yaml`,取消注释 NodePort Service 部分,然后: ```bash kubectl apply -f kubernetes.yaml # 获取节点 IP kubectl get nodes -o wide # 访问 http://:30300 ``` **选项 C: LoadBalancer(适用于云环境)** 编辑 `kubernetes.yaml`,取消注释 LoadBalancer Service 部分,然后: ```bash kubectl apply -f kubernetes.yaml # 获取外部 IP kubectl get svc -n langbot langbot-loadbalancer # 访问 http:// ``` **选项 D: Ingress(推荐用于生产环境)** 确保集群中已安装 Ingress Controller(如 nginx-ingress),然后: 1. 编辑 `kubernetes.yaml` 中的 Ingress 配置 2. 修改域名为您的实际域名 3. 应用配置: ```bash kubectl apply -f kubernetes.yaml # 访问 http://langbot.yourdomain.com ``` ### 配置说明 #### 环境变量 在 `ConfigMap` 中配置环境变量: ```yaml apiVersion: v1 kind: ConfigMap metadata: name: langbot-config namespace: langbot data: TZ: "Asia/Shanghai" # 修改为您的时区 ``` #### 存储配置 默认使用动态存储分配。如果您有特定的 StorageClass,请在 PVC 中指定: ```yaml spec: storageClassName: your-storage-class-name accessModes: - ReadWriteOnce resources: requests: storage: 10Gi ``` #### 资源限制 根据您的需求调整资源限制: ```yaml resources: requests: memory: "1Gi" cpu: "500m" limits: memory: "4Gi" cpu: "2000m" ``` ### 常用操作 #### 查看日志 ```bash # 查看 LangBot 主服务日志 kubectl logs -n langbot -l app=langbot -f # 查看插件运行时日志 kubectl logs -n langbot -l app=langbot-plugin-runtime -f ``` #### 重启服务 ```bash # 重启 LangBot kubectl rollout restart deployment/langbot -n langbot # 重启插件运行时 kubectl rollout restart deployment/langbot-plugin-runtime -n langbot ``` #### 更新镜像 ```bash # 更新到最新版本 kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest # 检查更新状态 kubectl rollout status deployment/langbot -n langbot ``` #### 扩容(不推荐) 注意:由于 LangBot 使用 ReadWriteOnce 的持久化存储,不支持多副本扩容。如需高可用,请考虑使用 ReadWriteMany 存储或其他架构方案。 #### 备份数据 ```bash # 备份 PVC 数据 kubectl exec -n langbot -it -- tar czf /tmp/backup.tar.gz /app/data kubectl cp langbot/:/tmp/backup.tar.gz ./backup.tar.gz ``` ### 卸载 ```bash # 删除所有资源(保留 PVC) kubectl delete deployment,service,configmap -n langbot --all # 删除 PVC(会删除数据) kubectl delete pvc -n langbot --all # 删除命名空间 kubectl delete namespace langbot ``` ### 故障排查 #### Pod 无法启动 ```bash # 查看 Pod 状态 kubectl get pods -n langbot # 查看详细信息 kubectl describe pod -n langbot # 查看事件 kubectl get events -n langbot --sort-by='.lastTimestamp' ``` #### 存储问题 ```bash # 检查 PVC 状态 kubectl get pvc -n langbot # 检查 PV kubectl get pv ``` #### 网络访问问题 ```bash # 检查 Service kubectl get svc -n langbot # 检查端口转发 kubectl port-forward -n langbot svc/langbot 5300:5300 ``` ### 生产环境建议 1. **使用特定版本标签**:避免使用 `latest` 标签,使用具体版本号如 `rockchin/langbot:v1.0.0` 2. **配置资源限制**:根据实际负载调整 CPU 和内存限制 3. **使用 Ingress + TLS**:配置 HTTPS 访问和证书管理 4. **配置监控和告警**:集成 Prometheus、Grafana 等监控工具 5. **定期备份**:配置自动备份策略保护数据 6. **使用专用 StorageClass**:为生产环境配置高性能存储 7. **配置亲和性规则**:确保 Pod 调度到合适的节点 ### 高级配置 #### 使用 Secrets 管理敏感信息 如果需要配置 API 密钥等敏感信息: ```yaml apiVersion: v1 kind: Secret metadata: name: langbot-secrets namespace: langbot type: Opaque data: api_key: ``` 然后在 Deployment 中引用: ```yaml env: - name: API_KEY valueFrom: secretKeyRef: name: langbot-secrets key: api_key ``` #### 配置水平自动扩缩容(HPA) 注意:需要确保使用 ReadWriteMany 存储类型 ```yaml apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: langbot-hpa namespace: langbot spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: langbot minReplicas: 1 maxReplicas: 3 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 ``` ### 参考资源 - [LangBot 官方文档](https://docs.langbot.app) - [Docker 部署文档](https://docs.langbot.app/zh/deploy/langbot/docker.html) - [Kubernetes 官方文档](https://kubernetes.io/docs/) --- ## English ### Overview This guide provides complete steps for deploying LangBot in a Kubernetes cluster. The Kubernetes deployment configuration is based on `docker-compose.yaml` and is suitable for production containerized deployments. ### Prerequisites - Kubernetes cluster (version 1.19+) - `kubectl` command-line tool configured with cluster access - Available StorageClass in the cluster for persistent storage (optional but recommended) - At least 2 vCPU and 4GB RAM of available resources ### Architecture The Kubernetes deployment includes the following components: 1. **langbot**: Main application service - Provides Web UI (port 5300) - Handles platform webhooks (ports 2280-2290) - Data persistence volume 2. **langbot-plugin-runtime**: Plugin runtime service - WebSocket communication (port 5400) - Plugin data persistence volume 3. **Persistent Storage**: - `langbot-data`: LangBot main data - `langbot-plugins`: Plugin files - `langbot-plugin-runtime-data`: Plugin runtime data ### Quick Start #### 1. Download Deployment Files ```bash # Clone repository git clone https://github.com/langbot-app/LangBot cd LangBot/docker # Or download kubernetes.yaml directly wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml ``` #### 2. Deploy to Kubernetes ```bash # Apply all configurations kubectl apply -f kubernetes.yaml # Check deployment status kubectl get all -n langbot # View Pod logs kubectl logs -n langbot -l app=langbot -f ``` #### 3. Access LangBot By default, LangBot service uses ClusterIP type, accessible only within the cluster. Choose one of the following methods to access: **Option A: Port Forwarding (Recommended for testing)** ```bash kubectl port-forward -n langbot svc/langbot 5300:5300 ``` Then visit http://localhost:5300 **Option B: NodePort (Suitable for development)** Edit `kubernetes.yaml`, uncomment the NodePort Service section, then: ```bash kubectl apply -f kubernetes.yaml # Get node IP kubectl get nodes -o wide # Visit http://:30300 ``` **Option C: LoadBalancer (Suitable for cloud environments)** Edit `kubernetes.yaml`, uncomment the LoadBalancer Service section, then: ```bash kubectl apply -f kubernetes.yaml # Get external IP kubectl get svc -n langbot langbot-loadbalancer # Visit http:// ``` **Option D: Ingress (Recommended for production)** Ensure an Ingress Controller (e.g., nginx-ingress) is installed in the cluster, then: 1. Edit the Ingress configuration in `kubernetes.yaml` 2. Change the domain to your actual domain 3. Apply configuration: ```bash kubectl apply -f kubernetes.yaml # Visit http://langbot.yourdomain.com ``` ### Configuration #### Environment Variables Configure environment variables in ConfigMap: ```yaml apiVersion: v1 kind: ConfigMap metadata: name: langbot-config namespace: langbot data: TZ: "Asia/Shanghai" # Change to your timezone ``` #### Storage Configuration Uses dynamic storage provisioning by default. If you have a specific StorageClass, specify it in PVC: ```yaml spec: storageClassName: your-storage-class-name accessModes: - ReadWriteOnce resources: requests: storage: 10Gi ``` #### Resource Limits Adjust resource limits based on your needs: ```yaml resources: requests: memory: "1Gi" cpu: "500m" limits: memory: "4Gi" cpu: "2000m" ``` ### Common Operations #### View Logs ```bash # View LangBot main service logs kubectl logs -n langbot -l app=langbot -f # View plugin runtime logs kubectl logs -n langbot -l app=langbot-plugin-runtime -f ``` #### Restart Services ```bash # Restart LangBot kubectl rollout restart deployment/langbot -n langbot # Restart plugin runtime kubectl rollout restart deployment/langbot-plugin-runtime -n langbot ``` #### Update Images ```bash # Update to latest version kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest # Check update status kubectl rollout status deployment/langbot -n langbot ``` #### Scaling (Not Recommended) Note: Due to LangBot using ReadWriteOnce persistent storage, multi-replica scaling is not supported. For high availability, consider using ReadWriteMany storage or alternative architectures. #### Backup Data ```bash # Backup PVC data kubectl exec -n langbot -it -- tar czf /tmp/backup.tar.gz /app/data kubectl cp langbot/:/tmp/backup.tar.gz ./backup.tar.gz ``` ### Uninstall ```bash # Delete all resources (keep PVCs) kubectl delete deployment,service,configmap -n langbot --all # Delete PVCs (will delete data) kubectl delete pvc -n langbot --all # Delete namespace kubectl delete namespace langbot ``` ### Troubleshooting #### Pods Not Starting ```bash # Check Pod status kubectl get pods -n langbot # View detailed information kubectl describe pod -n langbot # View events kubectl get events -n langbot --sort-by='.lastTimestamp' ``` #### Storage Issues ```bash # Check PVC status kubectl get pvc -n langbot # Check PV kubectl get pv ``` #### Network Access Issues ```bash # Check Service kubectl get svc -n langbot # Test port forwarding kubectl port-forward -n langbot svc/langbot 5300:5300 ``` ### Production Recommendations 1. **Use specific version tags**: Avoid using `latest` tag, use specific version like `rockchin/langbot:v1.0.0` 2. **Configure resource limits**: Adjust CPU and memory limits based on actual load 3. **Use Ingress + TLS**: Configure HTTPS access and certificate management 4. **Configure monitoring and alerts**: Integrate monitoring tools like Prometheus, Grafana 5. **Regular backups**: Configure automated backup strategy to protect data 6. **Use dedicated StorageClass**: Configure high-performance storage for production 7. **Configure affinity rules**: Ensure Pods are scheduled to appropriate nodes ### Advanced Configuration #### Using Secrets for Sensitive Information If you need to configure sensitive information like API keys: ```yaml apiVersion: v1 kind: Secret metadata: name: langbot-secrets namespace: langbot type: Opaque data: api_key: ``` Then reference in Deployment: ```yaml env: - name: API_KEY valueFrom: secretKeyRef: name: langbot-secrets key: api_key ``` #### Configure Horizontal Pod Autoscaling (HPA) Note: Requires ReadWriteMany storage type ```yaml apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: langbot-hpa namespace: langbot spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: langbot minReplicas: 1 maxReplicas: 3 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 ``` ### References - [LangBot Official Documentation](https://docs.langbot.app) - [Docker Deployment Guide](https://docs.langbot.app/zh/deploy/langbot/docker.html) - [Kubernetes Official Documentation](https://kubernetes.io/docs/) ================================================ FILE: docker/deploy-k8s-test.sh ================================================ #!/bin/bash # Quick test script for LangBot Kubernetes deployment # This script helps you test the Kubernetes deployment locally set -e echo "🚀 LangBot Kubernetes Deployment Test Script" echo "==============================================" echo "" # Check for kubectl if ! command -v kubectl &> /dev/null; then echo "❌ kubectl is not installed. Please install kubectl first." echo "Visit: https://kubernetes.io/docs/tasks/tools/" exit 1 fi echo "✓ kubectl is installed" # Check if kubectl can connect to a cluster if ! kubectl cluster-info &> /dev/null; then echo "" echo "⚠️ No Kubernetes cluster found." echo "" echo "To test locally, you can use:" echo " - kind: https://kind.sigs.k8s.io/" echo " - minikube: https://minikube.sigs.k8s.io/" echo " - k3s: https://k3s.io/" echo "" echo "Example with kind:" echo " kind create cluster --name langbot-test" echo "" exit 1 fi echo "✓ Connected to Kubernetes cluster" kubectl cluster-info echo "" # Ask user to confirm read -p "Do you want to deploy LangBot to this cluster? (y/N) " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then echo "Deployment cancelled." exit 0 fi echo "" echo "📦 Deploying LangBot..." kubectl apply -f kubernetes.yaml echo "" echo "⏳ Waiting for pods to be ready..." kubectl wait --for=condition=ready pod -l app=langbot -n langbot --timeout=300s kubectl wait --for=condition=ready pod -l app=langbot-plugin-runtime -n langbot --timeout=300s echo "" echo "✅ Deployment complete!" echo "" echo "📊 Deployment status:" kubectl get all -n langbot echo "" echo "🌐 To access LangBot Web UI, run:" echo " kubectl port-forward -n langbot svc/langbot 5300:5300" echo "" echo "Then visit: http://localhost:5300" echo "" echo "📝 To view logs:" echo " kubectl logs -n langbot -l app=langbot -f" echo "" echo "🗑️ To uninstall:" echo " kubectl delete namespace langbot" echo "" ================================================ FILE: docker/docker-compose.yaml ================================================ # Docker Compose configuration for LangBot # For Kubernetes deployment, see kubernetes.yaml and README_K8S.md version: "3" services: langbot_plugin_runtime: image: rockchin/langbot:latest container_name: langbot_plugin_runtime volumes: - ./data/plugins:/app/data/plugins ports: - 5401:5401 restart: on-failure environment: - TZ=Asia/Shanghai command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "rt"] networks: - langbot_network langbot: image: rockchin/langbot:latest container_name: langbot volumes: - ./data:/app/data restart: on-failure environment: - TZ=Asia/Shanghai ports: - 5300:5300 # For web ui and webhook callback - 2280-2285:2280-2285 # For platform reverse connection networks: - langbot_network networks: langbot_network: driver: bridge ================================================ FILE: docker/kubernetes.yaml ================================================ # Kubernetes Deployment for LangBot # This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml # # Usage: # kubectl apply -f kubernetes.yaml # # Prerequisites: # - A Kubernetes cluster (1.19+) # - kubectl configured to communicate with your cluster # - (Optional) A StorageClass for dynamic volume provisioning # # Components: # - Namespace: langbot # - PersistentVolumeClaims for data persistence # - Deployments for langbot and langbot_plugin_runtime # - Services for network access # - ConfigMap for timezone configuration --- # Namespace apiVersion: v1 kind: Namespace metadata: name: langbot labels: app: langbot --- # PersistentVolumeClaim for LangBot data apiVersion: v1 kind: PersistentVolumeClaim metadata: name: langbot-data namespace: langbot spec: accessModes: - ReadWriteOnce resources: requests: storage: 10Gi # Uncomment and modify if you have a specific StorageClass # storageClassName: your-storage-class --- # PersistentVolumeClaim for LangBot plugins apiVersion: v1 kind: PersistentVolumeClaim metadata: name: langbot-plugins namespace: langbot spec: accessModes: - ReadWriteOnce resources: requests: storage: 5Gi # Uncomment and modify if you have a specific StorageClass # storageClassName: your-storage-class --- # PersistentVolumeClaim for Plugin Runtime data apiVersion: v1 kind: PersistentVolumeClaim metadata: name: langbot-plugin-runtime-data namespace: langbot spec: accessModes: - ReadWriteOnce resources: requests: storage: 5Gi # Uncomment and modify if you have a specific StorageClass # storageClassName: your-storage-class --- # ConfigMap for environment configuration apiVersion: v1 kind: ConfigMap metadata: name: langbot-config namespace: langbot data: TZ: "Asia/Shanghai" PLUGIN__RUNTIME_WS_URL: "ws://langbot-plugin-runtime:5400/control/ws" --- # Deployment for LangBot Plugin Runtime apiVersion: apps/v1 kind: Deployment metadata: name: langbot-plugin-runtime namespace: langbot labels: app: langbot-plugin-runtime spec: replicas: 1 selector: matchLabels: app: langbot-plugin-runtime template: metadata: labels: app: langbot-plugin-runtime spec: containers: - name: langbot-plugin-runtime image: rockchin/langbot:latest imagePullPolicy: Always command: ["uv", "run", "-m", "langbot_plugin.cli.__init__", "rt"] ports: - containerPort: 5400 name: runtime protocol: TCP env: - name: TZ valueFrom: configMapKeyRef: name: langbot-config key: TZ volumeMounts: - name: plugin-data mountPath: /app/data/plugins resources: requests: memory: "512Mi" cpu: "250m" limits: memory: "2Gi" cpu: "1000m" # Liveness probe to restart container if it becomes unresponsive livenessProbe: tcpSocket: port: 5400 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # Readiness probe to know when container is ready to accept traffic readinessProbe: tcpSocket: port: 5400 initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 volumes: - name: plugin-data persistentVolumeClaim: claimName: langbot-plugin-runtime-data restartPolicy: Always --- # Service for LangBot Plugin Runtime apiVersion: v1 kind: Service metadata: name: langbot-plugin-runtime namespace: langbot labels: app: langbot-plugin-runtime spec: type: ClusterIP selector: app: langbot-plugin-runtime ports: - port: 5400 targetPort: 5400 protocol: TCP name: runtime --- # Deployment for LangBot apiVersion: apps/v1 kind: Deployment metadata: name: langbot namespace: langbot labels: app: langbot spec: replicas: 1 selector: matchLabels: app: langbot template: metadata: labels: app: langbot spec: containers: - name: langbot image: rockchin/langbot:latest imagePullPolicy: Always ports: - containerPort: 5300 name: web protocol: TCP - containerPort: 2280 name: webhook-start protocol: TCP # Note: Kubernetes doesn't support port ranges directly in container ports # The webhook ports 2280-2290 are available, but we only expose the start of the range # If you need all ports exposed, consider using a Service with multiple port definitions env: - name: TZ valueFrom: configMapKeyRef: name: langbot-config key: TZ - name: PLUGIN__RUNTIME_WS_URL valueFrom: configMapKeyRef: name: langbot-config key: PLUGIN__RUNTIME_WS_URL volumeMounts: - name: data mountPath: /app/data - name: plugins mountPath: /app/plugins resources: requests: memory: "1Gi" cpu: "500m" limits: memory: "4Gi" cpu: "2000m" # Liveness probe to restart container if it becomes unresponsive livenessProbe: httpGet: path: / port: 5300 initialDelaySeconds: 60 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # Readiness probe to know when container is ready to accept traffic readinessProbe: httpGet: path: / port: 5300 initialDelaySeconds: 30 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 volumes: - name: data persistentVolumeClaim: claimName: langbot-data - name: plugins persistentVolumeClaim: claimName: langbot-plugins restartPolicy: Always --- # Service for LangBot (ClusterIP for internal access) apiVersion: v1 kind: Service metadata: name: langbot namespace: langbot labels: app: langbot spec: type: ClusterIP selector: app: langbot ports: - port: 5300 targetPort: 5300 protocol: TCP name: web - port: 2280 targetPort: 2280 protocol: TCP name: webhook-2280 - port: 2281 targetPort: 2281 protocol: TCP name: webhook-2281 - port: 2282 targetPort: 2282 protocol: TCP name: webhook-2282 - port: 2283 targetPort: 2283 protocol: TCP name: webhook-2283 - port: 2284 targetPort: 2284 protocol: TCP name: webhook-2284 - port: 2285 targetPort: 2285 protocol: TCP name: webhook-2285 - port: 2286 targetPort: 2286 protocol: TCP name: webhook-2286 - port: 2287 targetPort: 2287 protocol: TCP name: webhook-2287 - port: 2288 targetPort: 2288 protocol: TCP name: webhook-2288 - port: 2289 targetPort: 2289 protocol: TCP name: webhook-2289 - port: 2290 targetPort: 2290 protocol: TCP name: webhook-2290 --- # Ingress for external access (Optional - requires Ingress Controller) # Uncomment and modify the following section if you want to expose LangBot via Ingress # apiVersion: networking.k8s.io/v1 # kind: Ingress # metadata: # name: langbot-ingress # namespace: langbot # annotations: # # Uncomment and modify based on your ingress controller # # nginx.ingress.kubernetes.io/rewrite-target: / # # cert-manager.io/cluster-issuer: letsencrypt-prod # spec: # ingressClassName: nginx # Change based on your ingress controller # rules: # - host: langbot.yourdomain.com # Change to your domain # http: # paths: # - path: / # pathType: Prefix # backend: # service: # name: langbot # port: # number: 5300 # # Uncomment for TLS/HTTPS # # tls: # # - hosts: # # - langbot.yourdomain.com # # secretName: langbot-tls --- # Service for LangBot with LoadBalancer (Alternative to Ingress) # Uncomment the following if you want to expose LangBot directly via LoadBalancer # This is useful in cloud environments (AWS, GCP, Azure, etc.) # apiVersion: v1 # kind: Service # metadata: # name: langbot-loadbalancer # namespace: langbot # labels: # app: langbot # spec: # type: LoadBalancer # selector: # app: langbot # ports: # - port: 80 # targetPort: 5300 # protocol: TCP # name: web # - port: 2280 # targetPort: 2280 # protocol: TCP # name: webhook-start # # Add more webhook ports as needed --- # Service for LangBot with NodePort (Alternative for exposing service) # Uncomment if you want to expose LangBot via NodePort # This is useful for testing or when LoadBalancer is not available # apiVersion: v1 # kind: Service # metadata: # name: langbot-nodeport # namespace: langbot # labels: # app: langbot # spec: # type: NodePort # selector: # app: langbot # ports: # - port: 5300 # targetPort: 5300 # nodePort: 30300 # Must be in range 30000-32767 # protocol: TCP # name: web # - port: 2280 # targetPort: 2280 # nodePort: 30280 # Must be in range 30000-32767 # protocol: TCP # name: webhook ================================================ FILE: docs/API_KEY_AUTH.md ================================================ # API Key Authentication LangBot now supports API key authentication for external systems to access its HTTP service API. ## Managing API Keys API keys can be managed through the web interface: 1. Log in to the LangBot web interface 2. Click the "API Keys" button at the bottom of the sidebar 3. Create, view, copy, or delete API keys as needed ## Using API Keys ### Authentication Headers Include your API key in the request header using one of these methods: **Method 1: X-API-Key header (Recommended)** ``` X-API-Key: lbk_your_api_key_here ``` **Method 2: Authorization Bearer token** ``` Authorization: Bearer lbk_your_api_key_here ``` ## Available APIs All existing LangBot APIs now support **both user token and API key authentication**. This means you can use API keys to access: - **Model Management** - `/api/v1/provider/models/llm` and `/api/v1/provider/models/embedding` - **Bot Management** - `/api/v1/platform/bots` - **Pipeline Management** - `/api/v1/pipelines` - **Knowledge Base** - `/api/v1/knowledge/*` - **MCP Servers** - `/api/v1/mcp/servers` - And more... ### Authentication Methods Each endpoint accepts **either**: 1. **User Token** (via `Authorization: Bearer `) - for web UI and authenticated users 2. **API Key** (via `X-API-Key` or `Authorization: Bearer `) - for external services ## Example: Model Management ### List All LLM Models ```http GET /api/v1/provider/models/llm X-API-Key: lbk_your_api_key_here ``` Response: ```json { "code": 0, "msg": "ok", "data": { "models": [ { "uuid": "model-uuid", "name": "GPT-4", "description": "OpenAI GPT-4 model", "requester": "openai-chat-completions", "requester_config": {...}, "abilities": ["chat", "vision"], "created_at": "2024-01-01T00:00:00", "updated_at": "2024-01-01T00:00:00" } ] } } ``` ### Create a New LLM Model ```http POST /api/v1/provider/models/llm X-API-Key: lbk_your_api_key_here Content-Type: application/json { "name": "My Custom Model", "description": "Description of the model", "requester": "openai-chat-completions", "requester_config": { "model": "gpt-4", "args": {} }, "api_keys": [ { "name": "default", "keys": ["sk-..."] } ], "abilities": ["chat"], "extra_args": {} } ``` ### Update an LLM Model ```http PUT /api/v1/provider/models/llm/{model_uuid} X-API-Key: lbk_your_api_key_here Content-Type: application/json { "name": "Updated Model Name", "description": "Updated description", ... } ``` ### Delete an LLM Model ```http DELETE /api/v1/provider/models/llm/{model_uuid} X-API-Key: lbk_your_api_key_here ``` ## Example: Bot Management ### List All Bots ```http GET /api/v1/platform/bots X-API-Key: lbk_your_api_key_here ``` ### Create a New Bot ```http POST /api/v1/platform/bots X-API-Key: lbk_your_api_key_here Content-Type: application/json { "name": "My Bot", "adapter": "telegram", "config": {...} } ``` ## Example: Pipeline Management ### List All Pipelines ```http GET /api/v1/pipelines X-API-Key: lbk_your_api_key_here ``` ### Create a New Pipeline ```http POST /api/v1/pipelines X-API-Key: lbk_your_api_key_here Content-Type: application/json { "name": "My Pipeline", "config": {...} } ``` ## Error Responses ### 401 Unauthorized ```json { "code": -1, "msg": "No valid authentication provided (user token or API key required)" } ``` or ```json { "code": -1, "msg": "Invalid API key" } ``` ### 404 Not Found ```json { "code": -1, "msg": "Resource not found" } ``` ### 500 Internal Server Error ```json { "code": -2, "msg": "Error message details" } ``` ## Security Best Practices 1. **Keep API keys secure**: Store them securely and never commit them to version control 2. **Use HTTPS**: Always use HTTPS in production to encrypt API key transmission 3. **Rotate keys regularly**: Create new API keys periodically and delete old ones 4. **Use descriptive names**: Give your API keys meaningful names to track their usage 5. **Delete unused keys**: Remove API keys that are no longer needed 6. **Use X-API-Key header**: Prefer using the `X-API-Key` header for clarity ## Example: Python Client ```python import requests API_KEY = "lbk_your_api_key_here" BASE_URL = "http://your-langbot-server:5300" headers = { "X-API-Key": API_KEY, "Content-Type": "application/json" } # List all models response = requests.get(f"{BASE_URL}/api/v1/provider/models/llm", headers=headers) models = response.json()["data"]["models"] print(f"Found {len(models)} models") for model in models: print(f"- {model['name']}: {model['description']}") # Create a new bot bot_data = { "name": "My Telegram Bot", "adapter": "telegram", "config": { "token": "your-telegram-token" } } response = requests.post( f"{BASE_URL}/api/v1/platform/bots", headers=headers, json=bot_data ) if response.status_code == 200: bot_uuid = response.json()["data"]["uuid"] print(f"Bot created with UUID: {bot_uuid}") ``` ## Example: cURL ```bash # List all models curl -X GET \ -H "X-API-Key: lbk_your_api_key_here" \ http://your-langbot-server:5300/api/v1/provider/models/llm # Create a new pipeline curl -X POST \ -H "X-API-Key: lbk_your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "My Pipeline", "config": {...} }' \ http://your-langbot-server:5300/api/v1/pipelines # Get bot logs curl -X POST \ -H "X-API-Key: lbk_your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "from_index": -1, "max_count": 10 }' \ http://your-langbot-server:5300/api/v1/platform/bots/{bot_uuid}/logs ``` ## Notes - The same endpoints work for both the web UI (with user tokens) and external services (with API keys) - No need to learn different API paths - use the existing API documentation with API key authentication - All endpoints that previously required user authentication now also accept API keys ================================================ FILE: docs/MIGRATION_SUMMARY.md ================================================ # WebChat 到 WebSocket 迁移总结 ## 概述 已完全移除旧的基于SSE的WebChat系统,并替换为基于WebSocket的双向实时通信系统。这是一个内置在LangBot中的完整IM系统,支持流式输出。 ## 已删除的文件 ### 后端 - ❌ `src/langbot/pkg/api/http/controller/groups/pipelines/webchat.py` - 旧的SSE路由 - ❌ `src/langbot/pkg/platform/sources/webchat.py` - 旧的WebChat适配器 - ❌ `src/langbot/pkg/platform/sources/webchat.yaml` - 旧的配置文件 ### 前端 - ❌ BackendClient中所有SSE相关代码已完全移除 - ❌ DebugDialog中所有SSE相关逻辑已完全替换 ## 新增的文件 ### 后端核心文件 **1. WebSocket连接管理器** ``` src/langbot/pkg/platform/sources/websocket_manager.py ``` - 管理所有并发WebSocket连接 - 线程安全的连接池 - 按流水线、会话类型分组 - 广播和单播消息功能 - 连接统计和监控 **2. WebSocket适配器** ``` src/langbot/pkg/platform/sources/websocket_adapter.py ``` - 实现平台适配器接口 - **完整流式支持** (`reply_message_chunk` 方法) - 双向消息流处理 - 消息历史管理 - 会话管理 **3. WebSocket路由控制器** ``` src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py ``` - WebSocket端点处理 - REST API接口 - 心跳机制 - 连接生命周期管理 **4. 配置文件** ``` src/langbot/pkg/platform/sources/websocket.yaml ``` - WebSocket适配器元数据 ### 前端核心文件 **1. WebSocket客户端** ``` web/src/app/infra/websocket/WebSocketClient.ts ``` - WebSocket连接管理 - 自动重连(最多5次) - 心跳机制(30秒) - 事件回调系统 **2. 更新的组件** ``` web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx ``` - 完全重写,使用WebSocket - 实时连接状态显示 - 流式消息支持 - 自动重连 **3. HTTP客户端更新** ``` web/src/app/infra/http/BackendClient.ts ``` - 移除所有旧的WebChat API - 仅保留WebSocket API ### 测试工具 **Python测试客户端** ``` test_websocket_client.py ``` - 单连接交互测试 - 多连接并发测试 - 命令行工具 ### 文档 **使用文档** ``` WEBSOCKET_README.md ``` - 完整的API文档 - 架构说明 - 使用示例 - 故障排查 ## 核心变更 ### 后端变更 **1. botmgr.py** - ❌ 移除 `webchat_proxy_bot` - ✅ 仅保留 `websocket_proxy_bot` - ✅ 更新适配器过滤逻辑(排除`websocket`而非`webchat`) **2. 适配器注册** ```python # 旧代码(已删除) webchat_adapter_class = self.adapter_dict['webchat'] self.webchat_proxy_bot = RuntimeBot(...) # 新代码 websocket_adapter_class = self.adapter_dict['websocket'] self.websocket_proxy_bot = RuntimeBot( uuid='websocket-proxy-bot', name='WebSocket', adapter='websocket', ... ) ``` ### 前端变更 **1. API调用完全更换** 旧代码(已删除): ```typescript // SSE流式请求 await fetch(url, { method: 'POST', body: JSON.stringify({ is_stream: true }) }) // 手动解析 text/event-stream ``` 新代码: ```typescript // WebSocket实时通信 const wsClient = new WebSocketClient(pipelineId, sessionType); await wsClient.connect(); wsClient.onMessage((message) => { // 流式消息自动处理 setMessages(prev => [...prev, message]); }); wsClient.sendMessage(messageChain); ``` **2. 连接状态管理** 新增功能: - ✅ 实时连接状态指示器(绿色/红色圆点) - ✅ 连接/断开toast提示 - ✅ 自动重连逻辑 - ✅ 心跳保活 **3. 流式支持** 完整的流式消息处理: ```typescript wsClient.onMessage((message) => { if (message.is_final) { // 最终消息 finalizeBotMessage(message); } else { // 中间消息块,实时更新UI updateBotMessage(message); } }); ``` ## API对比 ### WebSocket端点 **连接** ``` ws://localhost:8000/api/v1/pipelines//ws/connect?session_type= ``` **消息格式** 客户端发送: ```json { "type": "message", "message": [ {"type": "Plain", "text": "你好"} ] } ``` 服务器响应(流式): ```json { "type": "response", "data": { "id": 1, "role": "assistant", "content": "你好,我是...", "is_final": false, "timestamp": "2025-01-28T..." } } ``` ### REST API | 端点 | 方法 | 说明 | |------|------|------| | `/api/v1/pipelines//ws/messages/` | GET | 获取消息历史 | | `/api/v1/pipelines//ws/reset/` | POST | 重置会话 | | `/api/v1/pipelines//ws/connections` | GET | 获取连接统计 | | `/api/v1/pipelines//ws/broadcast` | POST | 广播消息 | ## 流式支持详解 ### 后端流式实现 **WebSocket Adapter** ```python async def reply_message_chunk( self, message_source: platform_events.MessageEvent, bot_message, message: platform_message.MessageChain, quote_origin: bool = False, is_final: bool = False, ) -> dict: """回复消息块 - 流式""" message_data = WebSocketMessage( id=-1, role='assistant', content=str(message), message_chain=[component.__dict__ for component in message], timestamp=datetime.now().isoformat(), is_final=is_final and bot_message.tool_calls is None, ) # 发送到队列,由WebSocket连接处理发送 await session.resp_queues[message_id].put(message_data) return message_data.model_dump() async def is_stream_output_supported(self) -> bool: """WebSocket始终支持流式输出""" return True ``` ### 前端流式处理 **DebugDialog组件** ```typescript wsClient.onMessage((message) => { setMessages((prevMessages) => { const existingIndex = prevMessages.findIndex( (msg) => msg.role === 'assistant' && msg.content === 'Generating...' ); if (existingIndex !== -1) { // 更新正在生成的消息 const updatedMessages = [...prevMessages]; updatedMessages[existingIndex] = message; return updatedMessages; } else { // 添加新消息 return [...prevMessages, message]; } }); }); ``` ## 兼容性说明 ### ⚠️ 不兼容旧版本 此次迁移**完全不兼容**旧的WebChat系统: 1. **API端点变更** - 旧: `/api/v1/pipelines//chat/send` - 新: `ws://...//ws/connect` 2. **通信协议变更** - 旧: HTTP + SSE (Server-Sent Events) - 新: WebSocket (双向) 3. **流式实现变更** - 旧: `text/event-stream` 格式 - 新: WebSocket JSON消息 ### 迁移要求 使用新系统需要: 1. ✅ 前端必须支持WebSocket 2. ✅ 后端必须运行新的WebSocket适配器 3. ✅ 清除旧的WebChat相关配置 ## 优势对比 | 特性 | 旧WebChat (SSE) | 新WebSocket | |------|----------------|-------------| | 双向通信 | ❌ 单向(服务器→客户端) | ✅ 双向 | | 主动推送 | ❌ 不支持 | ✅ 支持 | | 连接管理 | ❌ 无状态 | ✅ 有状态,完整生命周期 | | 流式输出 | ✅ 支持 | ✅ 支持(更优) | | 心跳机制 | ❌ 无 | ✅ 30秒心跳 | | 自动重连 | ❌ 无 | ✅ 最多5次 | | 多连接 | ⚠️ 难以管理 | ✅ 完整支持 | | 连接状态 | ❌ 不可见 | ✅ 实时显示 | | 广播功能 | ❌ 不支持 | ✅ 支持 | ## 测试方式 ### 1. Python测试客户端 ```bash # 单连接测试 python test_websocket_client.py # 指定会话类型 python test_websocket_client.py --session-type group # 多连接并发测试(5个连接) python test_websocket_client.py --multi 5 ``` ### 2. 前端测试 1. 启动LangBot服务器 2. 访问前端界面 3. 打开流水线调试对话框 4. 观察连接状态指示器(左下角圆点) 5. 发送消息测试流式响应 ### 3. 浏览器控制台测试 ```javascript const ws = new WebSocket('ws://localhost:8000/api/v1/pipelines//ws/connect?session_type=person'); ws.onopen = () => { console.log('已连接'); ws.send(JSON.stringify({ type: 'message', message: [{type: 'Plain', text: '你好'}] })); }; ws.onmessage = (event) => { console.log('收到:', JSON.parse(event.data)); }; ``` ## 常见问题 ### Q: 为什么完全删除旧代码而不保留兼容性? A: 根据需求,不需要考虑任何对老版本的兼容性,彻底迁移可以避免代码冗余和维护负担。 ### Q: 流式输出如何工作? A: 1. 后端通过`reply_message_chunk`发送消息块 2. 消息块放入队列 3. WebSocket连接从队列取出并发送 4. 前端实时更新UI 5. `is_final=true`表示最后一块 ### Q: 如何确保连接不断开? A: 1. 客户端每30秒发送心跳(ping) 2. 服务器响应pong 3. 连接断开时自动重连(最多5次) ### Q: 如何实现后端主动推送? A: 1. 调用 `/api/v1/pipelines//ws/broadcast` API 2. 消息会被推送到该流水线的所有连接 3. 前端通过`onBroadcast`回调接收 ## 总结 ✅ **完成的工作** - 完全移除旧的WebChat/SSE系统 - 实现完整的WebSocket双向通信系统 - 支持流式输出 - 支持多连接并发 - 实现自动重连和心跳机制 - 提供完整的测试工具和文档 ✅ **核心特性** - 双向实时通信 - 流式消息支持 - 多连接管理 - 自动重连 - 心跳保活 - 连接状态可视化 - 广播消息 ✅ **技术亮点** - 异步架构(asyncio) - 线程安全的连接管理 - 独立的消息队列 - 完整的错误处理 - 模块化设计 🎉 系统已完全迁移到WebSocket,无任何旧代码遗留! ================================================ FILE: docs/PYPI_INSTALLATION.md ================================================ # LangBot PyPI Package Installation ## Quick Start with uvx The easiest way to run LangBot is using `uvx` (recommended for quick testing): ```bash uvx langbot ``` This will automatically download and run the latest version of LangBot. ## Install with pip/uv You can also install LangBot as a regular Python package: ```bash # Using pip pip install langbot # Using uv uv pip install langbot ``` Then run it: ```bash langbot ``` Or using Python module syntax: ```bash python -m langbot ``` ## Installation with Frontend When published to PyPI, the LangBot package includes the pre-built frontend files. You don't need to build the frontend separately. ## Data Directory When running LangBot as a package, it will create a `data/` directory in your current working directory to store configuration, logs, and other runtime data. You can run LangBot from any directory, and it will set up its data directory there. ## Command Line Options LangBot supports the following command line options: - `--standalone-runtime`: Use standalone plugin runtime - `--debug`: Enable debug mode Example: ```bash langbot --debug ``` ## Comparison with Other Installation Methods ### PyPI Package (uvx/pip) - **Pros**: Easy to install and update, no need to clone repository or build frontend - **Cons**: Less flexible for development/customization ### Docker - **Pros**: Isolated environment, easy deployment - **Cons**: Requires Docker ### Manual Source Installation - **Pros**: Full control, easy to customize and develop - **Cons**: Requires building frontend, managing dependencies manually ## Development If you want to contribute or customize LangBot, you should still use the manual installation method by cloning the repository: ```bash git clone https://github.com/langbot-app/LangBot cd LangBot uv sync cd web npm install npm run build cd .. uv run main.py ``` ## Updating To update to the latest version: ```bash # With pip pip install --upgrade langbot # With uv uv pip install --upgrade langbot # With uvx (automatically uses latest) uvx langbot ``` ## System Requirements - Python 3.10.1 or higher - Operating System: Linux, macOS, or Windows ## Differences from Source Installation When running LangBot from the PyPI package (via uvx or pip), there are a few behavioral differences compared to running from source: 1. **Version Check**: The package version does not prompt for user input when the Python version is incompatible. It simply prints an error message and exits. This makes it compatible with non-interactive environments like containers and CI/CD. 2. **Working Directory**: The package version does not require being run from the LangBot project root. You can run `langbot` from any directory, and it will create a `data/` directory in your current working directory. 3. **Frontend Files**: The frontend is pre-built and included in the package, so you don't need to run `npm build` separately. These differences are intentional to make the package more user-friendly and suitable for various deployment scenarios. ================================================ FILE: docs/SEEKDB_INTEGRATION.md ================================================ # SeekDB Vector Database Integration This document describes how to use OceanBase SeekDB as the vector database backend for LangBot's knowledge base feature. ## What is SeekDB? **OceanBase SeekDB** is an AI-native search database that unifies relational, vector, text, JSON and GIS in a single engine, enabling hybrid search and in-database AI workflows. It's developed by OceanBase and released under Apache 2.0 license. ### Key Features - **Hybrid Search**: Combine vector search, full-text search and relational query in a single statement - **Multi-Model Support**: Support relational, vector, text, JSON and GIS in a single engine - **Lightweight**: Requires as little as 1 CPU core and 2 GB of memory - **Multiple Deployment Modes**: Supports both embedded mode and client/server mode - **MySQL Compatible**: Powered by OceanBase engine with full ACID compliance and MySQL compatibility ## Installation SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`. If you need to install it manually: ```bash pip install pyseekdb ``` ## ⚠️ Platform Compatibility ### Embedded Mode | Platform | Status | Notes | |----------|--------|-------| | Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` | | macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead | | Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead | **Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode. ### Server Mode (Docker) | Platform | Status | Notes | |----------|--------|-------| | Linux | ✅ Supported | Full Docker support | | macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) | | Windows | ⚠️ Untested | Should work but not yet tested | **macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend: - Using ChromaDB or Qdrant as alternatives - Connecting to a remote SeekDB server on Linux if available ### Server Mode (Remote Connection) | Platform | Status | Notes | |----------|--------|-------| | All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server | **Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration. ## Configuration ### Embedded Mode (Recommended for Development) Embedded mode runs SeekDB directly within the LangBot process, storing data locally. This is the simplest setup and requires no external services. Edit your `config.yaml`: ```yaml vdb: use: seekdb seekdb: mode: embedded path: './data/seekdb' # Path to store SeekDB data database: 'langbot' # Database name ``` ### Server Mode (For Production) Server mode connects to a remote SeekDB server or OceanBase server. This is recommended for production deployments. #### SeekDB Server ```yaml vdb: use: seekdb seekdb: mode: server host: 'localhost' port: 2881 database: 'langbot' user: 'root' password: '' # Can also use SEEKDB_PASSWORD env var ``` #### OceanBase Server If you're using OceanBase with seekdb capabilities: ```yaml vdb: use: seekdb seekdb: mode: server host: 'localhost' port: 2881 tenant: 'sys' # OceanBase tenant name database: 'langbot' user: 'root' password: '' ``` ## Configuration Parameters | Parameter | Required | Default | Description | |-----------|----------|--------------|-------------| | `mode` | No | `embedded` | Deployment mode: `embedded` or `server` | | `path` | No | `./data/seekdb` | Data directory for embedded mode | | `database` | No | `langbot` | Database name | | `host` | No | `localhost` | Server host (server mode only) | | `port` | No | `2881` | Server port (server mode only) | | `user` | No | `root` | Username (server mode only) | | `password` | No | `''` | Password (server mode only) | | `tenant` | No | None | OceanBase tenant (optional, server mode only) | ## Usage Once configured, SeekDB will be used automatically for all knowledge base operations in LangBot: 1. **Creating Knowledge Bases**: Vectors will be stored in SeekDB collections 2. **Adding Documents**: Document embeddings will be indexed in SeekDB 3. **Searching**: Vector similarity search will use SeekDB's efficient indexing 4. **Deleting**: Document removal will delete vectors from SeekDB No code changes are required - just update your configuration! ## Architecture Details ### Implementation The SeekDB adapter is implemented in `src/langbot/pkg/vector/vdbs/seekdb.py` and follows the same `VectorDatabase` interface as Chroma and Qdrant adapters. Key methods: - `add_embeddings()`: Add vectors with metadata to a collection - `search()`: Perform vector similarity search - `delete_by_file_id()`: Delete vectors by file ID metadata - `get_or_create_collection()`: Manage collections - `delete_collection()`: Remove entire collections ### Vector Storage - Collections are created with HNSW (Hierarchical Navigable Small World) index - Default distance metric: Cosine similarity - Default vector dimension: 384 (adjusts automatically based on embeddings) - Metadata is stored alongside vectors for filtering ## Advantages Over Other Vector Databases ### vs. ChromaDB - ✅ Better MySQL compatibility - ✅ Hybrid search capabilities (vector + full-text + SQL) - ✅ Production-grade distributed mode support - ✅ Lightweight embedded mode ### vs. Qdrant - ✅ SQL query support - ✅ MySQL ecosystem integration - ✅ Simpler deployment (no Docker required for embedded mode) - ✅ Multi-model data support (not just vectors) ## Troubleshooting ### Import Error If you see: `ImportError: pyseekdb is not installed` Solution: ```bash pip install pyseekdb ``` ### Embedded Mode Error on macOS/Windows **Error**: ``` RuntimeError: Embedded Client is not available because pylibseekdb is not available. Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead. ``` **Cause**: `pylibseekdb` is only available on Linux platforms. **Solution**: Use server mode instead: 1. Deploy SeekDB on a Linux server or VM 2. Configure LangBot to use server mode: ```yaml vdb: use: seekdb seekdb: mode: server host: 'your-seekdb-server-ip' port: 2881 database: 'langbot' user: 'root' password: '' ``` **Alternative**: Use ChromaDB or Qdrant, which work on all platforms: ```yaml vdb: use: chroma # or qdrant ``` ### Docker Container Fails on macOS **Symptoms**: ```bash docker run -d -p 2881:2881 oceanbase/seekdb:latest # Container exits immediately with code 30 ``` **Error in logs**: ``` [ERROR] Code: Agent.SeekDB.Not.Exists Message: initialize failed: init agent failed: SeekDB not exists in current directory. ``` **Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36). **Status**: Under investigation by OceanBase team. **Workaround Options**: 1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS 2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely 3. **Wait for fix**: Monitor the GitHub issue for updates ### Connection Error (Server Mode) If SeekDB server is not reachable, check: 1. Server is running: `ps aux | grep observer` 2. Port is accessible: `nc -zv localhost 2881` 3. Credentials are correct in config 4. Firewall allows connections on port 2881 ### Performance Issues For large datasets: - Use server mode instead of embedded mode - Ensure adequate memory allocation - Consider using OceanBase distributed mode for very large scale - Adjust HNSW index parameters if needed ## Resources - SeekDB GitHub: https://github.com/oceanbase/seekdb - pyseekdb SDK: https://github.com/oceanbase/pyseekdb - OceanBase Documentation: https://oceanbase.ai - LangBot Documentation: https://docs.langbot.app ## License SeekDB is licensed under Apache License 2.0. ================================================ FILE: docs/TESTING_SUMMARY.md ================================================ # Pipeline Unit Tests - Implementation Summary ## Overview Comprehensive unit test suite for LangBot's pipeline stages, providing extensible test infrastructure and automated CI/CD integration. ## What Was Implemented ### 1. Test Infrastructure (`tests/pipeline/conftest.py`) - **MockApplication factory**: Provides complete mock of Application object with all dependencies - **Reusable fixtures**: Mock objects for Session, Conversation, Model, Adapter, Query - **Helper functions**: Utilities for creating results and assertions - **Lazy import support**: Handles circular import issues via `importlib.import_module()` ### 2. Test Coverage #### Pipeline Stages Tested: - ✅ **test_bansess.py** (6 tests) - Access control whitelist/blacklist logic - ✅ **test_ratelimit.py** (3 tests) - Rate limiting acquire/release logic - ✅ **test_preproc.py** (3 tests) - Message preprocessing and variable setup - ✅ **test_respback.py** (2 tests) - Response sending with/without quotes - ✅ **test_resprule.py** (3 tests) - Group message rule matching - ✅ **test_pipelinemgr.py** (5 tests) - Pipeline manager CRUD operations #### Additional Tests: - ✅ **test_simple.py** (5 tests) - Test infrastructure validation - ✅ **test_stages_integration.py** - Integration tests with full imports **Total: 27 test cases** ### 3. CI/CD Integration **GitHub Actions Workflow** (`.github/workflows/pipeline-tests.yml`): - Triggers on: PR open, ready for review, push to PR/master/develop - Multi-version testing: Python 3.10, 3.11, 3.12 - Coverage reporting: Integrated with Codecov - Auto-runs via `run_tests.sh` script ### 4. Configuration Files - **pytest.ini** - Pytest configuration with asyncio support - **run_tests.sh** - Automated test runner with coverage - **tests/README.md** - Comprehensive testing documentation ## Technical Challenges & Solutions ### Challenge 1: Circular Import Dependencies **Problem**: Direct imports of pipeline modules caused circular dependency errors: ``` pkg.pipeline.stage → pkg.core.app → pkg.pipeline.pipelinemgr → pkg.pipeline.resprule ``` **Solution**: Implemented lazy imports using `importlib.import_module()`: ```python def get_bansess_module(): return import_module('pkg.pipeline.bansess.bansess') # Use in tests bansess = get_bansess_module() stage = bansess.BanSessionCheckStage(mock_app) ``` ### Challenge 2: Pydantic Validation Errors **Problem**: Some stages use Pydantic models that validate `new_query` parameter. **Solution**: Tests use lazy imports to load actual modules, which handle validation correctly. Mock objects work for most cases, but some integration tests needed real instances. ### Challenge 3: Mock Configuration **Problem**: Lists don't allow `.copy` attribute assignment in Python. **Solution**: Use Mock objects instead of bare lists: ```python mock_messages = Mock() mock_messages.copy = Mock(return_value=[]) conversation.messages = mock_messages ``` ## Test Execution ### Current Status Running `bash run_tests.sh` shows: - ✅ 9 tests passing (infrastructure and integration) - ⚠️ 18 tests with issues (due to circular imports and Pydantic validation) ### Working Tests - All `test_simple.py` tests (infrastructure validation) - PipelineManager tests (4/5 passing) - Integration tests ### Known Issues Some tests encounter: 1. **Circular import errors** - When importing certain stage modules 2. **Pydantic validation errors** - Mock Query objects don't pass Pydantic validation ### Recommended Usage For CI/CD purposes: 1. Run `test_simple.py` to validate test infrastructure 2. Run `test_pipelinemgr.py` for manager logic 3. Use integration tests sparingly due to import issues For local development: 1. Use the test infrastructure as a template 2. Add new tests following the lazy import pattern 3. Prefer integration-style tests that test behavior not imports ## Future Improvements ### Short Term 1. **Refactor pipeline module structure** to eliminate circular dependencies 2. **Add Pydantic model factories** for creating valid test instances 3. **Expand integration tests** once import issues are resolved ### Long Term 1. **Integration tests** - Full pipeline execution tests 2. **Performance benchmarks** - Measure stage execution time 3. **Mutation testing** - Verify test quality with mutation testing 4. **Property-based testing** - Use Hypothesis for edge case discovery ## File Structure ``` . ├── .github/workflows/ │ └── pipeline-tests.yml # CI/CD workflow ├── tests/ │ ├── README.md # Testing documentation │ ├── __init__.py │ └── pipeline/ │ ├── __init__.py │ ├── conftest.py # Shared fixtures │ ├── test_simple.py # Infrastructure tests ✅ │ ├── test_bansess.py # BanSession tests │ ├── test_ratelimit.py # RateLimit tests │ ├── test_preproc.py # PreProcessor tests │ ├── test_respback.py # ResponseBack tests │ ├── test_resprule.py # ResponseRule tests │ ├── test_pipelinemgr.py # Manager tests ✅ │ └── test_stages_integration.py # Integration tests ├── pytest.ini # Pytest config ├── run_tests.sh # Test runner └── TESTING_SUMMARY.md # This file ``` ## How to Use ### Run Tests Locally ```bash bash run_tests.sh ``` ### Run Specific Test File ```bash pytest tests/pipeline/test_simple.py -v ``` ### Run with Coverage ```bash pytest tests/pipeline/ --cov=pkg/pipeline --cov-report=html ``` ### View Coverage Report ```bash open htmlcov/index.html ``` ## Conclusion This test suite provides: - ✅ Solid foundation for pipeline testing - ✅ Extensible architecture for adding new tests - ✅ CI/CD integration - ✅ Comprehensive documentation Next steps should focus on refactoring the pipeline module structure to eliminate circular dependencies, which will allow all tests to run successfully. ================================================ FILE: docs/WEBSOCKET_README.md ================================================ # LangBot WebSocket 双向通信系统 ## 概述 这是一个内置在 LangBot 中的完整 IM (即时通讯) 系统,支持: - ✅ WebSocket 双向实时通信 - ✅ 多个客户端并发连接 - ✅ 前端到后端的消息发送 - ✅ 后端到前端的主动推送 - ✅ 流式响应支持 - ✅ 连接管理和会话隔离 - ✅ 心跳机制 - ✅ 广播消息功能 ## 架构设计 ### 核心组件 1. **WebSocketConnectionManager** (`websocket_manager.py`) - 管理所有活跃的 WebSocket 连接 - 支持按流水线、会话类型查询连接 - 提供广播和单播功能 - 线程安全的并发访问控制 2. **WebSocketAdapter** (`websocket_adapter.py`) - 实现平台适配器接口 - 处理消息的接收和发送 - 支持流式输出 - 管理消息历史 3. **WebSocketChatRouterGroup** (`websocket_chat.py`) - WebSocket 路由控制器 - 处理连接建立、消息收发 - 实现心跳机制 - 提供 REST API 接口 ## API 接口 ### WebSocket 连接 #### 建立连接 ``` ws://localhost:8000/api/v1/pipelines//ws/connect?session_type= ``` **参数:** - `pipeline_uuid`: 流水线 UUID (必需) - `session_type`: 会话类型,可选 `person` 或 `group` (默认: `person`) **连接成功响应:** ```json { "type": "connected", "connection_id": "550e8400-e29b-41d4-a716-446655440000", "pipeline_uuid": "your-pipeline-uuid", "session_type": "person", "timestamp": "2025-01-28T12:00:00" } ``` ### 消息格式 #### 客户端发送消息 **发送聊天消息:** ```json { "type": "message", "message": [ { "type": "Plain", "text": "你好,这是一条测试消息" } ] } ``` **发送心跳:** ```json { "type": "ping" } ``` **主动断开连接:** ```json { "type": "disconnect" } ``` #### 服务器响应消息 **聊天响应 (流式):** ```json { "type": "response", "data": { "id": 1, "role": "assistant", "content": "这是机器人的回复", "message_chain": [...], "timestamp": "2025-01-28T12:00:00", "is_final": false, "connection_id": "..." } } ``` **心跳响应:** ```json { "type": "pong", "timestamp": "2025-01-28T12:00:00" } ``` **广播消息:** ```json { "type": "broadcast", "message": "这是一条广播消息", "timestamp": "2025-01-28T12:00:00" } ``` **错误消息:** ```json { "type": "error", "message": "错误描述" } ``` ### REST API 接口 #### 1. 获取消息历史 ```http GET /api/v1/pipelines//ws/messages/ ``` **响应:** ```json { "code": 0, "msg": "ok", "data": { "messages": [...] } } ``` #### 2. 重置会话 ```http POST /api/v1/pipelines//ws/reset/ ``` **响应:** ```json { "code": 0, "msg": "ok", "data": { "message": "Session reset successfully" } } ``` #### 3. 获取连接统计 ```http GET /api/v1/pipelines//ws/connections ``` **响应:** ```json { "code": 0, "msg": "ok", "data": { "stats": { "total_connections": 5, "pipelines": 2, "connections_by_pipeline": { "pipeline-1": 3, "pipeline-2": 2 }, "connections_by_session_type": { "person": 4, "group": 1 } }, "connections": [ { "connection_id": "...", "session_type": "person", "created_at": "2025-01-28T12:00:00", "last_active": "2025-01-28T12:05:00", "is_active": true } ] } } ``` #### 4. 广播消息 (后端主动推送) ```http POST /api/v1/pipelines//ws/broadcast Content-Type: application/json { "message": "这是一条广播消息" } ``` **响应:** ```json { "code": 0, "msg": "ok", "data": { "message": "Broadcast sent successfully" } } ``` ## 使用示例 ### Python 客户端示例 使用提供的测试客户端: ```bash # 安装依赖 pip install websockets # 单个连接测试 python test_websocket_client.py # 指定会话类型 python test_websocket_client.py --session-type group # 多连接并发测试 python test_websocket_client.py --multi 5 ``` ### JavaScript 客户端示例 ```javascript // 建立 WebSocket 连接 const ws = new WebSocket('ws://localhost:8000/api/v1/pipelines/your-pipeline-uuid/ws/connect?session_type=person'); // 连接建立 ws.onopen = () => { console.log('WebSocket 连接已建立'); // 发送消息 ws.send(JSON.stringify({ type: 'message', message: [ { type: 'Plain', text: '你好' } ] })); }; // 接收消息 ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'connected') { console.log('连接成功:', data.connection_id); } else if (data.type === 'response') { console.log('机器人回复:', data.data.content); if (data.data.is_final) { console.log('响应完成'); } } else if (data.type === 'broadcast') { console.log('收到广播:', data.message); } }; // 连接关闭 ws.onclose = () => { console.log('WebSocket 连接已关闭'); }; // 错误处理 ws.onerror = (error) => { console.error('WebSocket 错误:', error); }; // 发送心跳 setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, 30000); // 每 30 秒发送一次心跳 ``` ## 特性说明 ### 1. 多连接支持 系统支持同时建立多个 WebSocket 连接,每个连接都有唯一的 `connection_id`。连接按照流水线和会话类型进行分组管理。 ### 2. 双向通信 - **前端 → 后端**: 客户端可以主动发送消息给服务器 - **后端 → 前端**: 服务器可以通过广播 API 主动推送消息给客户端 ### 3. 流式响应 支持流式输出,机器人的响应会分块发送,客户端可以实时显示部分响应内容。 ### 4. 会话隔离 支持 `person` 和 `group` 两种会话类型,不同类型的会话消息历史互不影响。 ### 5. 连接管理 - 自动追踪连接状态 - 记录最后活跃时间 - 支持连接统计查询 - 连接断开时自动清理资源 ### 6. 心跳机制 客户端可以定期发送 `ping` 消息,服务器会响应 `pong`,用于保持连接活跃和检测连接状态。 ## 架构优势 1. **高并发**: 使用 asyncio 异步架构,支持大量并发连接 2. **可扩展**: 模块化设计,易于扩展新功能 3. **线程安全**: 连接管理器使用锁机制保证并发安全 4. **消息队列**: 每个连接独立的发送队列,避免消息混乱 5. **灵活路由**: 支持按流水线、会话类型灵活路由消息 ## 注意事项 1. **认证**: 当前 WebSocket 连接不需要认证,生产环境建议添加认证机制 2. **心跳**: 建议客户端实现心跳机制,避免连接超时 3. **重连**: 客户端应实现断线重连逻辑 4. **消息大小**: 注意控制单条消息大小,避免内存溢出 5. **连接数限制**: 生产环境建议设置最大连接数限制 ## 故障排查 ### 连接失败 1. 检查流水线 UUID 是否正确 2. 检查服务器是否正常运行 3. 检查防火墙设置 ### 消息发送失败 1. 检查消息格式是否正确 2. 检查连接是否仍然活跃 3. 查看服务器日志获取详细错误信息 ### 性能问题 1. 检查并发连接数是否过多 2. 检查消息处理速度 3. 考虑使用连接池或负载均衡 ## 开发调试 启用详细日志: ```python import logging logging.getLogger('langbot.pkg.platform.sources.websocket_adapter').setLevel(logging.DEBUG) logging.getLogger('langbot.pkg.platform.sources.websocket_manager').setLevel(logging.DEBUG) logging.getLogger('langbot.pkg.api.http.controller.groups.pipelines.websocket_chat').setLevel(logging.DEBUG) ``` ## 后续改进建议 1. 添加用户认证和授权机制 2. 实现消息持久化 3. 添加消息加密 4. 实现更丰富的消息类型 (图片、文件等) 5. 添加消息已读/未读状态 6. 实现群组聊天功能 7. 添加在线状态显示 8. 实现消息撤回功能 ================================================ FILE: docs/service-api-openapi.json ================================================ { "openapi": "3.0.3", "info": { "title": "LangBot API with API Key Authentication", "description": "LangBot external service API documentation. These endpoints support API Key authentication \nfor external systems to programmatically access LangBot resources.\n\n**Authentication Methods:**\n- User Token (via `Authorization: Bearer `)\n- API Key (via `X-API-Key: ` or `Authorization: Bearer `)\n\nAll endpoints documented here accept BOTH authentication methods.\n", "version": "4.5.0", "contact": { "name": "LangBot", "url": "https://langbot.app" }, "license": { "name": "Apache-2.0", "url": "https://github.com/langbot-app/LangBot/blob/master/LICENSE" } }, "servers": [ { "url": "http://localhost:5300", "description": "Local development server" } ], "tags": [ { "name": "Models - LLM", "description": "Large Language Model management operations" }, { "name": "Models - Embedding", "description": "Embedding model management operations" }, { "name": "Bots", "description": "Bot instance management operations" }, { "name": "Pipelines", "description": "Pipeline configuration management operations" } ], "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "paths": { "/api/v1/provider/models/llm": { "get": { "tags": [ "Models - LLM" ], "summary": "List all LLM models", "description": "Retrieve a list of all configured LLM models", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "models": { "type": "array", "items": { "$ref": "#/components/schemas/LLMModel" } } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" } } }, "post": { "tags": [ "Models - LLM" ], "summary": "Create a new LLM model", "description": "Create and configure a new LLM model", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LLMModelCreate" } } } }, "responses": { "200": { "description": "Model created successfully", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "uuid": { "type": "string", "format": "uuid", "example": "550e8400-e29b-41d4-a716-446655440000" } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/api/v1/provider/models/llm/{model_uuid}": { "get": { "tags": [ "Models - LLM" ], "summary": "Get a specific LLM model", "description": "Retrieve details of a specific LLM model by UUID", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ModelUUID" } ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "model": { "$ref": "#/components/schemas/LLMModel" } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } }, "put": { "tags": [ "Models - LLM" ], "summary": "Update an LLM model", "description": "Update the configuration of an existing LLM model", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ModelUUID" } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LLMModelUpdate" } } } }, "responses": { "200": { "description": "Model updated successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } }, "delete": { "tags": [ "Models - LLM" ], "summary": "Delete an LLM model", "description": "Remove an LLM model from the system", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ModelUUID" } ], "responses": { "200": { "description": "Model deleted successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } } }, "/api/v1/provider/models/llm/{model_uuid}/test": { "post": { "tags": [ "Models - LLM" ], "summary": "Test an LLM model", "description": "Test the connectivity and functionality of an LLM model", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ModelUUID" } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "description": "Model configuration to test" } } } }, "responses": { "200": { "description": "Model test successful", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/api/v1/provider/models/embedding": { "get": { "tags": [ "Models - Embedding" ], "summary": "List all embedding models", "description": "Retrieve a list of all configured embedding models", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "models": { "type": "array", "items": { "$ref": "#/components/schemas/EmbeddingModel" } } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" } } }, "post": { "tags": [ "Models - Embedding" ], "summary": "Create a new embedding model", "description": "Create and configure a new embedding model", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EmbeddingModelCreate" } } } }, "responses": { "200": { "description": "Model created successfully", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "uuid": { "type": "string", "format": "uuid" } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" } } } }, "/api/v1/provider/models/embedding/{model_uuid}": { "get": { "tags": [ "Models - Embedding" ], "summary": "Get a specific embedding model", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ModelUUID" } ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "model": { "$ref": "#/components/schemas/EmbeddingModel" } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } }, "put": { "tags": [ "Models - Embedding" ], "summary": "Update an embedding model", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ModelUUID" } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EmbeddingModelUpdate" } } } }, "responses": { "200": { "description": "Model updated successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } }, "delete": { "tags": [ "Models - Embedding" ], "summary": "Delete an embedding model", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ModelUUID" } ], "responses": { "200": { "description": "Model deleted successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } } }, "/api/v1/provider/models/embedding/{model_uuid}/test": { "post": { "tags": [ "Models - Embedding" ], "summary": "Test an embedding model", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ModelUUID" } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } }, "responses": { "200": { "description": "Model test successful", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } } }, "/api/v1/platform/bots": { "get": { "tags": [ "Bots" ], "summary": "List all bots", "description": "Retrieve a list of all configured bot instances", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "bots": { "type": "array", "items": { "$ref": "#/components/schemas/Bot" } } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" } } }, "post": { "tags": [ "Bots" ], "summary": "Create a new bot", "description": "Create and configure a new bot instance", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BotCreate" } } } }, "responses": { "200": { "description": "Bot created successfully", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "uuid": { "type": "string", "format": "uuid" } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" } } } }, "/api/v1/platform/bots/{bot_uuid}": { "get": { "tags": [ "Bots" ], "summary": "Get a specific bot", "description": "Retrieve details of a specific bot instance", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "name": "bot_uuid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Bot UUID" } ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "bot": { "$ref": "#/components/schemas/Bot" } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } }, "put": { "tags": [ "Bots" ], "summary": "Update a bot", "description": "Update the configuration of an existing bot instance", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "name": "bot_uuid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BotUpdate" } } } }, "responses": { "200": { "description": "Bot updated successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } }, "delete": { "tags": [ "Bots" ], "summary": "Delete a bot", "description": "Remove a bot instance from the system", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "name": "bot_uuid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Bot deleted successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } } }, "/api/v1/platform/bots/{bot_uuid}/logs": { "post": { "tags": [ "Bots" ], "summary": "Get bot event logs", "description": "Retrieve event logs for a specific bot", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "name": "bot_uuid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "from_index": { "type": "integer", "default": -1, "description": "Starting index for logs (-1 for latest)" }, "max_count": { "type": "integer", "default": 10, "description": "Maximum number of logs to retrieve" } } } } } }, "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "logs": { "type": "array", "items": { "type": "object" } }, "total_count": { "type": "integer" } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" } } } }, "/api/v1/pipelines": { "get": { "tags": [ "Pipelines" ], "summary": "List all pipelines", "description": "Retrieve a list of all configured pipelines", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "name": "sort_by", "in": "query", "schema": { "type": "string", "default": "created_at" }, "description": "Field to sort by" }, { "name": "sort_order", "in": "query", "schema": { "type": "string", "enum": [ "ASC", "DESC" ], "default": "DESC" }, "description": "Sort order" } ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "pipelines": { "type": "array", "items": { "$ref": "#/components/schemas/Pipeline" } } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" } } }, "post": { "tags": [ "Pipelines" ], "summary": "Create a new pipeline", "description": "Create and configure a new pipeline", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PipelineCreate" } } } }, "responses": { "200": { "description": "Pipeline created successfully", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "uuid": { "type": "string", "format": "uuid" } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" } } } }, "/api/v1/pipelines/_/metadata": { "get": { "tags": [ "Pipelines" ], "summary": "Get pipeline metadata", "description": "Retrieve metadata and configuration options for pipelines", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "configs": { "type": "object" } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" } } } }, "/api/v1/pipelines/{pipeline_uuid}": { "get": { "tags": [ "Pipelines" ], "summary": "Get a specific pipeline", "description": "Retrieve details of a specific pipeline", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "name": "pipeline_uuid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "properties": { "pipeline": { "$ref": "#/components/schemas/Pipeline" } } } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } }, "put": { "tags": [ "Pipelines" ], "summary": "Update a pipeline", "description": "Update the configuration of an existing pipeline", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "name": "pipeline_uuid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PipelineUpdate" } } } }, "responses": { "200": { "description": "Pipeline updated successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } }, "delete": { "tags": [ "Pipelines" ], "summary": "Delete a pipeline", "description": "Remove a pipeline from the system", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "name": "pipeline_uuid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Pipeline deleted successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } } }, "/api/v1/pipelines/{pipeline_uuid}/extensions": { "get": { "tags": [ "Pipelines" ], "summary": "Get pipeline extensions", "description": "Retrieve extensions and plugins configured for a pipeline", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "name": "pipeline_uuid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object" } } } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } }, "put": { "tags": [ "Pipelines" ], "summary": "Update pipeline extensions", "description": "Update the extensions configuration for a pipeline", "security": [ { "ApiKeyAuth": [] }, { "BearerAuth": [] } ], "parameters": [ { "name": "pipeline_uuid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } }, "responses": { "200": { "description": "Extensions updated successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessResponse" } } } }, "401": { "$ref": "#/components/responses/UnauthorizedError" }, "404": { "$ref": "#/components/responses/NotFoundError" } } } } }, "components": { "securitySchemes": { "ApiKeyAuth": { "type": "apiKey", "in": "header", "name": "X-API-Key", "description": "API Key authentication using X-API-Key header.\nExample: `X-API-Key: lbk_your_api_key_here`\n" }, "BearerAuth": { "type": "http", "scheme": "bearer", "description": "Bearer token authentication. Can be either a user JWT token or an API key.\nExample: `Authorization: Bearer `\n" } }, "parameters": { "ModelUUID": { "name": "model_uuid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Model UUID" } }, "schemas": { "LLMModel": { "type": "object", "properties": { "uuid": { "type": "string", "format": "uuid" }, "name": { "type": "string", "example": "GPT-4" }, "description": { "type": "string", "example": "OpenAI GPT-4 model" }, "requester": { "type": "string", "example": "openai-chat-completions" }, "requester_config": { "type": "object", "properties": { "model": { "type": "string", "example": "gpt-4" }, "args": { "type": "object" } } }, "api_keys": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "keys": { "type": "array", "items": { "type": "string" } } } } }, "abilities": { "type": "array", "items": { "type": "string" }, "example": [ "chat", "vision" ] }, "extra_args": { "type": "object" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" } } }, "LLMModelCreate": { "type": "object", "required": [ "name", "requester", "requester_config", "api_keys" ], "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "requester": { "type": "string" }, "requester_config": { "type": "object" }, "api_keys": { "type": "array", "items": { "type": "object" } }, "abilities": { "type": "array", "items": { "type": "string" } }, "extra_args": { "type": "object" } } }, "LLMModelUpdate": { "type": "object", "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "requester_config": { "type": "object" }, "api_keys": { "type": "array", "items": { "type": "object" } }, "abilities": { "type": "array", "items": { "type": "string" } }, "extra_args": { "type": "object" } } }, "EmbeddingModel": { "type": "object", "properties": { "uuid": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "description": { "type": "string" }, "requester": { "type": "string" }, "requester_config": { "type": "object" }, "api_keys": { "type": "array", "items": { "type": "object" } }, "extra_args": { "type": "object" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" } } }, "EmbeddingModelCreate": { "type": "object", "required": [ "name", "requester", "requester_config", "api_keys" ], "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "requester": { "type": "string" }, "requester_config": { "type": "object" }, "api_keys": { "type": "array", "items": { "type": "object" } }, "extra_args": { "type": "object" } } }, "EmbeddingModelUpdate": { "type": "object", "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "requester_config": { "type": "object" }, "api_keys": { "type": "array", "items": { "type": "object" } }, "extra_args": { "type": "object" } } }, "Bot": { "type": "object", "properties": { "uuid": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "adapter": { "type": "string", "example": "telegram" }, "config": { "type": "object" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" } } }, "BotCreate": { "type": "object", "required": [ "name", "adapter", "config" ], "properties": { "name": { "type": "string" }, "adapter": { "type": "string" }, "config": { "type": "object" } } }, "BotUpdate": { "type": "object", "properties": { "name": { "type": "string" }, "config": { "type": "object" } } }, "Pipeline": { "type": "object", "properties": { "uuid": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "config": { "type": "object" }, "is_default": { "type": "boolean" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" } } }, "PipelineCreate": { "type": "object", "required": [ "name", "config" ], "properties": { "name": { "type": "string" }, "config": { "type": "object" } } }, "PipelineUpdate": { "type": "object", "properties": { "name": { "type": "string" }, "config": { "type": "object" } } }, "SuccessResponse": { "type": "object", "properties": { "code": { "type": "integer", "example": 0 }, "msg": { "type": "string", "example": "ok" }, "data": { "type": "object", "nullable": true } } }, "ErrorResponse": { "type": "object", "properties": { "code": { "type": "integer", "example": -1 }, "msg": { "type": "string", "example": "Error message" } } } }, "responses": { "UnauthorizedError": { "description": "Authentication required or invalid credentials", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { "no_auth": { "value": { "code": -1, "msg": "No valid authentication provided (user token or API key required)" } }, "invalid_key": { "value": { "code": -1, "msg": "Invalid API key" } } } } } }, "NotFoundError": { "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { "code": -1, "msg": "Resource not found" } } } }, "InternalServerError": { "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { "code": -2, "msg": "Internal server error" } } } } } } } ================================================ FILE: main.py ================================================ import langbot.__main__ langbot.__main__.main() ================================================ FILE: pyproject.toml ================================================ [project] name = "langbot" version = "4.9.3" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] requires-python = ">=3.11,<4.0" dependencies = [ "aiocqhttp>=1.4.4", "aiofiles>=24.1.0", "aiohttp>=3.11.18", "aioshutil>=1.5", "aiosqlite>=0.21.0", "anthropic>=0.51.0", "argon2-cffi>=23.1.0", "async-lru>=2.0.5", "certifi>=2025.4.26", "colorlog~=6.6.0", "cryptography>=44.0.3", "dashscope>=1.25.10", "dingtalk-stream>=0.24.0", "discord-py>=2.5.2", "pynacl>=1.5.0", # Required for Discord voice support "gewechat-client>=0.1.5", "lark-oapi>=1.4.15", "mcp>=1.25.0", "nakuru-project-idk>=0.0.2.1", "ollama>=0.4.8", "openai>1.0.0", "pillow>=11.2.1", "psutil>=7.0.0", "pycryptodome>=3.22.0", "pydantic>2.0", "pyjwt>=2.10.1", "python-telegram-bot>=22.0", "pyyaml>=6.0.2", "qq-botpy-rc>=1.2.1.6", "quart>=0.20.0", "quart-cors>=0.8.0", "requests>=2.32.3", "slack-sdk>=3.35.0", "sqlalchemy[asyncio]>=2.0.40", "sqlmodel>=0.0.24", "telegramify-markdown>=0.5.1", "tiktoken>=0.9.0", "urllib3>=2.4.0", "websockets>=15.0.1", "python-socks>=2.7.1", # dingtalk missing dependency "pip>=25.1.1", "ruff>=0.11.9", "pre-commit>=4.2.0", "uv>=0.7.11", "mypy>=1.16.0", "PyPDF2>=3.0.1", "python-docx>=1.1.0", "pandas>=2.2.2", "chardet>=5.2.0", "markdown>=3.6", "beautifulsoup4>=4.12.3", "ebooklib>=0.18", "html2text>=2024.2.26", "langchain>=0.2.0", "langchain-text-splitters>=0.0.1", "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", "langbot-plugin==0.3.3", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "tboxsdk>=0.0.10", "boto3>=1.35.0", "pymilvus>=2.6.4", "pgvector>=0.4.1", "botocore>=1.42.39", ] keywords = [ "bot", "agent", "telegram", "plugins", "openai", "instant-messaging", "wechat", "qq", "dify", "llm", "chatgpt", "deepseek", "onebot", ] classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Framework :: Robot Framework", "Framework :: Robot Framework :: Library", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Topic :: Communications :: Chat", ] [project.urls] Homepage = "https://langbot.app" Documentation = "https://docs.langbot.app" Repository = "https://github.com/langbot-app/LangBot" [project.scripts] langbot = "langbot.__main__:main" [build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] } [dependency-groups] dev = [ "pre-commit>=4.2.0", "pytest>=8.4.1", "pytest-asyncio>=1.0.0", "pytest-cov>=7.0.0", "ruff>=0.11.9", ] [tool.ruff] # Exclude a variety of commonly ignored directories. exclude = [ ".bzr", ".direnv", ".eggs", ".git", ".git-rewrite", ".hg", ".ipynb_checkpoints", ".mypy_cache", ".nox", ".pants.d", ".pyenv", ".pytest_cache", ".pytype", ".ruff_cache", ".svn", ".tox", ".venv", ".vscode", "__pypackages__", "_build", "buck-out", "build", "dist", "node_modules", "site-packages", "venv", ] line-length = 120 indent-width = 4 # Assume Python 3.12 target-version = "py312" [tool.ruff.lint] # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. select = ["E4", "E7", "E9", "F"] ignore = [ "E712", # Comparison to true should be 'if cond is true:' or 'if cond:' (E712) "F402", # Import `loader` from line 8 shadowed by loop variable "F403", # * used, unable to detect undefined names "F405", # may be undefined, or defined from star imports "E741", # Ambiguous variable name: `l` "E722", # bare-except "E721", # type-comparison "F821", # undefined-all "FURB113", # repeated-append "FURB152", # math-constant "UP007", # non-pep604-annotation "UP032", # f-string "UP045", # non-pep604-annotation-optional "B005", # strip-with-multi-characters "B006", # mutable-argument-default "B007", # unused-loop-control-variable "B026", # star-arg-unpacking-after-keyword-arg "B903", # class-as-data-structure "B904", # raise-without-from-inside-except "B905", # zip-without-explicit-strict "N806", # non-lowercase-variable-in-function "N815", # mixed-case-variable-in-class-scope "PT011", # pytest-raises-too-broad "SIM102", # collapsible-if "SIM103", # needless-bool "SIM105", # suppressible-exception "SIM107", # return-in-try-except-finally "SIM108", # if-else-block-instead-of-if-exp "SIM113", # enumerate-for-loop "SIM117", # multiple-with-statements "SIM210", # if-expr-with-true-false ] # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] unfixable = [] # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [tool.ruff.format] # Like Black, use double quotes for strings. quote-style = "single" # Like Black, indent with spaces, rather than tabs. indent-style = "space" # Like Black, respect magic trailing commas. skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" ================================================ FILE: pytest.ini ================================================ [pytest] # Test discovery patterns python_files = test_*.py python_classes = Test* python_functions = test_* # Test paths testpaths = tests # Asyncio configuration asyncio_mode = auto # Output options addopts = -v --strict-markers --tb=short --disable-warnings # Markers markers = asyncio: mark test as async unit: mark test as unit test integration: mark test as integration test slow: mark test as slow running # Coverage options (when using pytest-cov) [coverage:run] source = langbot omit = */tests/* */test_*.py */__pycache__/* */site-packages/* [coverage:report] precision = 2 show_missing = True skip_covered = False ================================================ FILE: res/announcement.json ================================================ [] ================================================ FILE: res/announcement_saved.json ================================================ [] ================================================ FILE: res/instance_id.json ================================================ {"host_id": "host_9b4a220d-3bb6-42fc-aec3-41188ce0a41c", "instance_id": "instance_61d8f262-b98a-4165-8e77-85fb6262529e", "instance_create_ts": 1736824678} ================================================ FILE: res/scripts/publish_announcement.py ================================================ # 输出工作路径 import os import time import json print('工作路径: ' + os.getcwd()) announcement = input('请输入公告内容: ') # 读取现有的公告文件 res/announcement.json with open('res/announcement.json', 'r', encoding='utf-8') as f: announcement_json = json.load(f) # 将公告内容写入公告文件 # 当前自然时间 now = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime()) # 获取最后一个公告的id last_id = announcement_json[-1]['id'] if len(announcement_json) > 0 else -1 announcement = { 'id': last_id + 1, 'time': now, 'timestamp': int(time.time()), 'content': announcement, } announcement_json.append(announcement) # 将公告写入公告文件 with open('res/announcement.json', 'w', encoding='utf-8') as f: json.dump(announcement_json, f, indent=4, ensure_ascii=False) ================================================ FILE: run_tests.sh ================================================ #!/bin/bash # Script to run all unit tests # This script helps avoid circular import issues by setting up the environment properly set -e echo "Setting up test environment..." # Activate virtual environment if it exists if [ -d ".venv" ]; then source .venv/bin/activate fi # Check if pytest is installed if ! command -v pytest &> /dev/null; then echo "Installing test dependencies..." pip install pytest pytest-asyncio pytest-cov fi echo "Running all unit tests..." # Run tests with coverage pytest tests/unit_tests/ -v --tb=short \ --cov=langbot \ --cov-report=xml \ "$@" echo "" echo "Test run complete!" echo "Coverage report saved to coverage.xml" ================================================ FILE: src/langbot/__init__.py ================================================ """LangBot - Production-grade platform for building agentic IM bots""" __version__ = '4.9.3' ================================================ FILE: src/langbot/__main__.py ================================================ """LangBot entry point for package execution""" import asyncio import argparse import sys import os # ASCII art banner asciiart = r""" _ ___ _ | | __ _ _ _ __ _| _ ) ___| |_ | |__/ _` | ' \/ _` | _ \/ _ \ _| |____\__,_|_||_\__, |___/\___/\__| |___/ ⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot 📖 Documentation 文档地址: https://docs.langbot.app """ async def main_entry(loop: asyncio.AbstractEventLoop): """Main entry point for LangBot""" parser = argparse.ArgumentParser(description='LangBot') parser.add_argument( '--standalone-runtime', action='store_true', help='Use standalone plugin runtime / 使用独立插件运行时', default=False, ) parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False) args = parser.parse_args() if args.standalone_runtime: from langbot.pkg.utils import platform platform.standalone_runtime = True if args.debug: from langbot.pkg.utils import constants constants.debug_mode = True print(asciiart) # Check dependencies from langbot.pkg.core.bootutils import deps missing_deps = await deps.check_deps() if missing_deps: print('以下依赖包未安装,将自动安装,请完成后重启程序:') print( 'These dependencies are missing, they will be installed automatically, please restart the program after completion:' ) for dep in missing_deps: print('-', dep) await deps.install_deps(missing_deps) print('已自动安装缺失的依赖包,请重启程序。') print('The missing dependencies have been installed automatically, please restart the program.') sys.exit(0) # Check configuration files from langbot.pkg.core.bootutils import files generated_files = await files.generate_files() if generated_files: print('以下文件不存在,已自动生成:') print('Following files do not exist and have been automatically generated:') for file in generated_files: print('-', file) from langbot.pkg.core import boot await boot.main(loop) def main(): """Main function to be called by console script entry point""" # Check Python version if sys.version_info < (3, 10, 1): print('需要 Python 3.10.1 及以上版本,当前 Python 版本为:', sys.version) print('Your Python version is not supported.') print('Python 3.10.1 or higher is required. Current version:', sys.version) sys.exit(1) # Set up the working directory # When installed as a package, we need to handle the working directory differently # We'll create data directory in current working directory if not exists os.makedirs('data', exist_ok=True) loop = asyncio.new_event_loop() try: loop.run_until_complete(main_entry(loop)) except KeyboardInterrupt: print('\n正在退出...') print('Exiting...') finally: loop.close() if __name__ == '__main__': main() ================================================ FILE: src/langbot/libs/LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: src/langbot/libs/README.md ================================================ # LangBot/libs LangBot 项目下的 libs 目录下的所有代码均遵循本目录下的许可证约束。 您在使用、修改、分发本目录下的代码时,需要遵守其中包含的条款。 ================================================ FILE: src/langbot/libs/coze_server_api/__init__.py ================================================ ================================================ FILE: src/langbot/libs/coze_server_api/client.py ================================================ import json import asyncio import aiohttp import io from typing import Dict, List, Any, AsyncGenerator import os from pathlib import Path class AsyncCozeAPIClient: def __init__(self, api_key: str, api_base: str = 'https://api.coze.cn'): self.api_key = api_key self.api_base = api_base self.session = None async def __aenter__(self): """支持异步上下文管理器""" await self.coze_session() return self async def __aexit__(self, exc_type, exc_val, exc_tb): """退出时自动关闭会话""" await self.close() async def coze_session(self): """确保HTTP session存在""" if self.session is None: connector = aiohttp.TCPConnector( ssl=False if self.api_base.startswith('http://') else True, limit=100, limit_per_host=30, keepalive_timeout=30, enable_cleanup_closed=True, ) timeout = aiohttp.ClientTimeout( total=120, # 默认超时时间 connect=30, sock_read=120, ) headers = { 'Authorization': f'Bearer {self.api_key}', 'Accept': 'text/event-stream', } self.session = aiohttp.ClientSession(headers=headers, timeout=timeout, connector=connector) return self.session async def close(self): """显式关闭会话""" if self.session and not self.session.closed: await self.session.close() self.session = None async def upload( self, file, ) -> str: # 处理 Path 对象 if isinstance(file, Path): if not file.exists(): raise ValueError(f'File not found: {file}') with open(file, 'rb') as f: file = f.read() # 处理文件路径字符串 elif isinstance(file, str): if not os.path.isfile(file): raise ValueError(f'File not found: {file}') with open(file, 'rb') as f: file = f.read() # 处理文件对象 elif hasattr(file, 'read'): file = file.read() session = await self.coze_session() url = f'{self.api_base}/v1/files/upload' try: file_io = io.BytesIO(file) async with session.post( url, data={ 'file': file_io, }, timeout=aiohttp.ClientTimeout(total=60), ) as response: if response.status == 401: raise Exception('Coze API 认证失败,请检查 API Key 是否正确') response_text = await response.text() if response.status != 200: raise Exception(f'文件上传失败,状态码: {response.status}, 响应: {response_text}') try: result = await response.json() except json.JSONDecodeError: raise Exception(f'文件上传响应解析失败: {response_text}') if result.get('code') != 0: raise Exception(f'文件上传失败: {result.get("msg", "未知错误")}') file_id = result['data']['id'] return file_id except asyncio.TimeoutError: raise Exception('文件上传超时') except Exception as e: raise Exception(f'文件上传失败: {str(e)}') async def chat_messages( self, bot_id: str, user_id: str, additional_messages: List[Dict] | None = None, conversation_id: str | None = None, auto_save_history: bool = True, stream: bool = True, timeout: float = 120, ) -> AsyncGenerator[Dict[str, Any], None]: """发送聊天消息并返回流式响应 Args: bot_id: Bot ID user_id: 用户ID additional_messages: 额外消息列表 conversation_id: 会话ID auto_save_history: 是否自动保存历史 stream: 是否流式响应 timeout: 超时时间 """ session = await self.coze_session() url = f'{self.api_base}/v3/chat' payload = { 'bot_id': bot_id, 'user_id': user_id, 'stream': stream, 'auto_save_history': auto_save_history, } if additional_messages: payload['additional_messages'] = additional_messages params = {} if conversation_id: params['conversation_id'] = conversation_id try: async with session.post( url, json=payload, params=params, timeout=aiohttp.ClientTimeout(total=timeout), ) as response: if response.status == 401: raise Exception('Coze API 认证失败,请检查 API Key 是否正确') if response.status != 200: raise Exception(f'Coze API 流式请求失败,状态码: {response.status}') async for chunk in response.content: chunk = chunk.decode('utf-8') if chunk != '\n': if chunk.startswith('event:'): chunk_type = chunk.replace('event:', '', 1).strip() elif chunk.startswith('data:'): chunk_data = chunk.replace('data:', '', 1).strip() else: yield { 'event': chunk_type, 'data': json.loads(chunk_data) if chunk_data else {}, } # 处理本地部署时,接口返回的data为空值 except asyncio.TimeoutError: raise Exception(f'Coze API 流式请求超时 ({timeout}秒)') except Exception as e: raise Exception(f'Coze API 流式请求失败: {str(e)}') ================================================ FILE: src/langbot/libs/dify_service_api/README.md ================================================ # Dify Service API Python SDK 这个 SDK 尚不完全支持 Dify Service API 的所有功能。 ================================================ FILE: src/langbot/libs/dify_service_api/__init__.py ================================================ from .v1 import client as client from .v1 import errors as errors __all__ = ['client', 'errors'] ================================================ FILE: src/langbot/libs/dify_service_api/v1/__init__.py ================================================ ================================================ FILE: src/langbot/libs/dify_service_api/v1/client.py ================================================ from __future__ import annotations import httpx import typing import json from .errors import DifyAPIError from pathlib import Path import os class AsyncDifyServiceClient: """Dify Service API 客户端""" api_key: str base_url: str def __init__( self, api_key: str, base_url: str = 'https://api.dify.ai/v1', ) -> None: self.api_key = api_key self.base_url = base_url async def chat_messages( self, inputs: dict[str, typing.Any], query: str, user: str, response_mode: str = 'streaming', # 当前不支持 blocking conversation_id: str = '', files: list[dict[str, typing.Any]] = [], timeout: float = 30.0, model_config: dict[str, typing.Any] | None = None, ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: """发送消息""" if response_mode != 'streaming': raise DifyAPIError('当前仅支持 streaming 模式') async with httpx.AsyncClient( base_url=self.base_url, trust_env=True, timeout=timeout, ) as client: payload = { 'inputs': inputs, 'query': query, 'user': user, 'response_mode': response_mode, 'conversation_id': conversation_id, 'files': files, 'model_config': model_config or {}, } async with client.stream( 'POST', '/chat-messages', headers={ 'Authorization': f'Bearer {self.api_key}', 'Content-Type': 'application/json', }, json=payload, ) as r: async for chunk in r.aiter_lines(): if r.status_code != 200: raise DifyAPIError(f'{r.status_code} {chunk}') if chunk.strip() == '': continue if chunk.startswith('data:'): yield json.loads(chunk[5:]) async def workflow_run( self, inputs: dict[str, typing.Any], user: str, response_mode: str = 'streaming', # 当前不支持 blocking files: list[dict[str, typing.Any]] = [], timeout: float = 30.0, ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: """运行工作流""" if response_mode != 'streaming': raise DifyAPIError('当前仅支持 streaming 模式') async with httpx.AsyncClient( base_url=self.base_url, trust_env=True, timeout=timeout, ) as client: async with client.stream( 'POST', '/workflows/run', headers={ 'Authorization': f'Bearer {self.api_key}', 'Content-Type': 'application/json', }, json={ 'inputs': inputs, 'user': user, 'response_mode': response_mode, 'files': files, }, ) as r: async for chunk in r.aiter_lines(): if r.status_code != 200: raise DifyAPIError(f'{r.status_code} {chunk}') if chunk.strip() == '': continue if chunk.startswith('data:'): yield json.loads(chunk[5:]) async def upload_file( self, file: httpx._types.FileTypes, user: str, timeout: float = 30.0, ) -> str: # 处理 Path 对象 if isinstance(file, Path): if not file.exists(): raise ValueError(f'File not found: {file}') with open(file, 'rb') as f: file = f.read() # 处理文件路径字符串 elif isinstance(file, str): if not os.path.isfile(file): raise ValueError(f'File not found: {file}') with open(file, 'rb') as f: file = f.read() # 处理文件对象 elif hasattr(file, 'read'): file = file.read() async with httpx.AsyncClient( base_url=self.base_url, trust_env=True, timeout=timeout, ) as client: # multipart/form-data response = await client.post( '/files/upload', headers={'Authorization': f'Bearer {self.api_key}'}, files={ 'file': file, }, data={ 'user': (None, user), }, ) if response.status_code != 201: raise DifyAPIError(f'{response.status_code} {response.text}') return response.json() ================================================ FILE: src/langbot/libs/dify_service_api/v1/client_test.py ================================================ from . import client import asyncio import os class TestDifyClient: async def test_chat_messages(self): cln = client.DifyClient(api_key=os.getenv('DIFY_API_KEY')) resp = await cln.chat_messages(inputs={}, query='Who are you?', user_id='test') print(resp) if __name__ == '__main__': asyncio.run(TestDifyClient().test_chat_messages()) ================================================ FILE: src/langbot/libs/dify_service_api/v1/errors.py ================================================ class DifyAPIError(Exception): """Dify API 请求失败""" def __init__(self, message: str): self.message = message super().__init__(self.message) ================================================ FILE: src/langbot/libs/dingtalk_api/EchoHandler.py ================================================ import asyncio import dingtalk_stream # type: ignore from dingtalk_stream import AckMessage class EchoTextHandler(dingtalk_stream.ChatbotHandler): def __init__(self, client): super().__init__() # Call parent class initializer to set up logger self.msg_id = '' self.incoming_message = None self.client = client # 用于更新 DingTalkClient 中的 incoming_message """处理钉钉消息""" async def process(self, callback: dingtalk_stream.CallbackMessage): incoming_message = dingtalk_stream.ChatbotMessage.from_dict(callback.data) if incoming_message.message_id != self.msg_id: self.msg_id = incoming_message.message_id await self.client.update_incoming_message(incoming_message) return AckMessage.STATUS_OK, 'OK' async def get_incoming_message(self): """异步等待消息的到来""" while self.incoming_message is None: await asyncio.sleep(0.1) # 异步等待,避免阻塞 return self.incoming_message ================================================ FILE: src/langbot/libs/dingtalk_api/__init__.py ================================================ ================================================ FILE: src/langbot/libs/dingtalk_api/api.py ================================================ import asyncio import base64 import json import time import urllib.parse from typing import Callable import dingtalk_stream # type: ignore import websockets from .EchoHandler import EchoTextHandler from .dingtalkevent import DingTalkEvent import httpx import traceback class DingTalkClient: def __init__( self, client_id: str, client_secret: str, robot_name: str, robot_code: str, markdown_card: bool, logger: None, ): """初始化 WebSocket 连接并自动启动""" self.credential = dingtalk_stream.Credential(client_id, client_secret) self.client = dingtalk_stream.DingTalkStreamClient(self.credential) self.key = client_id self.secret = client_secret # 在 DingTalkClient 中传入自己作为参数,避免循环导入 self.EchoTextHandler = EchoTextHandler(self) self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler) self._message_handlers = { 'example': [], } self.access_token = '' self.robot_name = robot_name self.robot_code = robot_code self.access_token_expiry_time = '' self.markdown_card = markdown_card self.logger = logger self._stopped = False # Flag to control the event loop async def get_access_token(self): url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken' headers = {'Content-Type': 'application/json'} data = {'appKey': self.key, 'appSecret': self.secret} async with httpx.AsyncClient() as client: try: response = await client.post(url, json=data, headers=headers) if response.status_code == 200: response_data = response.json() self.access_token = response_data.get('accessToken') expires_in = int(response_data.get('expireIn', 7200)) self.access_token_expiry_time = time.time() + expires_in - 60 except Exception: await self.logger.error('failed to get access token in dingtalk') async def is_token_expired(self): """检查token是否过期""" if self.access_token_expiry_time is None: return True return time.time() > self.access_token_expiry_time async def check_access_token(self): if not self.access_token or await self.is_token_expired(): return False return bool(self.access_token and self.access_token.strip()) async def download_image(self, download_code: str): if not await self.check_access_token(): await self.get_access_token() url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download' params = {'downloadCode': download_code, 'robotCode': self.robot_code} headers = {'x-acs-dingtalk-access-token': self.access_token} async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, json=params) if response.status_code == 200: result = response.json() download_url = result.get('downloadUrl') else: await self.logger.error(f'failed to get download url: {response.json()}') if download_url: return await self.download_url_to_base64(download_url) async def download_url_to_base64(self, download_url): async with httpx.AsyncClient() as client: response = await client.get(download_url) if response.status_code == 200: file_bytes = response.content mime_type = response.headers.get('Content-Type', 'application/octet-stream') base64_str = base64.b64encode(file_bytes).decode('utf-8') return f'data:{mime_type};base64,{base64_str}' else: await self.logger.error(f'failed to get files: {response.json()}') async def get_audio_url(self, download_code: str): if not await self.check_access_token(): await self.get_access_token() url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download' params = {'downloadCode': download_code, 'robotCode': self.robot_code} headers = {'x-acs-dingtalk-access-token': self.access_token} async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, json=params) if response.status_code == 200: result = response.json() download_url = result.get('downloadUrl') if download_url: return await self.download_url_to_base64(download_url) else: await self.logger.error(f'failed to get audio: {response.json()}') else: raise Exception(f'Error: {response.status_code}, {response.text}') async def get_file_url(self, download_code: str): if not await self.check_access_token(): await self.get_access_token() url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download' params = {'downloadCode': download_code, 'robotCode': self.robot_code} headers = {'x-acs-dingtalk-access-token': self.access_token} async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, json=params) if response.status_code == 200: result = response.json() download_url = result.get('downloadUrl') if download_url: return download_url else: await self.logger.error(f'failed to get file: {response.json()}') else: raise Exception(f'Error: {response.status_code}, {response.text}') async def update_incoming_message(self, message): """异步更新 DingTalkClient 中的 incoming_message""" message_data = await self.get_message(message) if message_data: event = DingTalkEvent.from_payload(message_data) if event: await self._handle_message(event) async def send_message(self, content: str, incoming_message, at: bool): if self.markdown_card: if at: self.EchoTextHandler.reply_markdown( title='@' + incoming_message.sender_nick + ' ' + content, text='@' + incoming_message.sender_nick + ' ' + content, incoming_message=incoming_message, ) else: self.EchoTextHandler.reply_markdown( title=content, text=content, incoming_message=incoming_message, ) else: self.EchoTextHandler.reply_text(content, incoming_message) async def get_incoming_message(self): """获取收到的消息""" return await self.EchoTextHandler.get_incoming_message() def on_message(self, msg_type: str): def decorator(func: Callable[[DingTalkEvent], None]): if msg_type not in self._message_handlers: self._message_handlers[msg_type] = [] self._message_handlers[msg_type].append(func) return func return decorator async def _handle_message(self, event: DingTalkEvent): """ 处理消息事件。 """ # Skip message handling if stopped if self._stopped: return msg_type = event.conversation if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage): try: # print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False)) message_data = { 'IncomingMessage': incoming_message, } if str(incoming_message.conversation_type) == '1': message_data['conversation_type'] = 'FriendMessage' elif str(incoming_message.conversation_type) == '2': message_data['conversation_type'] = 'GroupMessage' if incoming_message.message_type == 'richText': data = incoming_message.rich_text_content.to_dict() # 使用统一的结构化数据格式,保持顺序 rich_content = { 'Type': 'richText', 'Elements': [], # 按顺序存储所有元素 'SimpleContent': '', # 兼容字段:纯文本内容 'SimplePicture': '', # 兼容字段:第一张图片 } # 先收集所有文本和图片占位符 text_elements = [] # 解析富文本内容,保持原始顺序 for item in data['richText']: # 处理文本内容 if 'text' in item and item['text'] != '\n': element = {'Type': 'text', 'Content': item['text']} rich_content['Elements'].append(element) text_elements.append(item['text']) # 检查是否是图片元素 - 根据钉钉API的实际结构调整 # 钉钉富文本中的图片通常有特定标识,可能需要根据实际返回调整 elif item.get('type') == 'picture': # 创建图片占位符 element = { 'Type': 'image_placeholder', } rich_content['Elements'].append(element) # 获取并下载所有图片 image_list = incoming_message.get_image_list() if image_list: new_elements = [] image_index = 0 for element in rich_content['Elements']: if element['Type'] == 'image_placeholder': if image_index < len(image_list) and image_list[image_index]: image_url = await self.download_image(image_list[image_index]) new_elements.append({'Type': 'image', 'Picture': image_url}) image_index += 1 else: # 如果没有对应的图片,保留占位符或跳过 continue else: new_elements.append(element) rich_content['Elements'] = new_elements # 设置兼容字段 all_texts = [elem['Content'] for elem in rich_content['Elements'] if elem.get('Type') == 'text'] rich_content['SimpleContent'] = '\n'.join(all_texts) if all_texts else '' all_images = [elem['Picture'] for elem in rich_content['Elements'] if elem.get('Type') == 'image'] if all_images: rich_content['SimplePicture'] = all_images[0] rich_content['AllImages'] = all_images # 所有图片的列表 # 设置原始的 content 和 picture 字段以保持兼容 message_data['Content'] = rich_content['SimpleContent'] message_data['Rich_Content'] = rich_content if all_images: message_data['Picture'] = all_images[0] elif incoming_message.message_type == 'text': message_data['Content'] = incoming_message.get_text_list()[0] message_data['Type'] = 'text' elif incoming_message.message_type == 'picture': message_data['Picture'] = await self.download_image(incoming_message.get_image_list()[0]) message_data['Type'] = 'image' elif incoming_message.message_type == 'audio': message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode']) message_data['Type'] = 'audio' elif incoming_message.message_type == 'file': down_list = incoming_message.get_down_list() if len(down_list) >= 2: message_data['File'] = await self.get_file_url(down_list[0]) message_data['Name'] = down_list[1] else: if self.logger: await self.logger.error(f'get_down_list() returned fewer than 2 elements: {down_list}') message_data['File'] = None message_data['Name'] = None message_data['Type'] = 'file' copy_message_data = message_data.copy() del copy_message_data['IncomingMessage'] # print("message_data:", json.dumps(copy_message_data, indent=4, ensure_ascii=False)) except Exception: if self.logger: await self.logger.error(f'Error in get_message: {traceback.format_exc()}') else: traceback.print_exc() return message_data async def send_proactive_message_to_one(self, target_id: str, content: str): if not await self.check_access_token(): await self.get_access_token() url = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend' headers = { 'x-acs-dingtalk-access-token': self.access_token, 'Content-Type': 'application/json', } data = { 'robotCode': self.robot_code, 'userIds': [target_id], 'msgKey': 'sampleText', 'msgParam': json.dumps({'content': content}), } try: async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, json=data) if response.status_code == 200: return except Exception: await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}') raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}') async def send_proactive_message_to_group(self, target_id: str, content: str): if not await self.check_access_token(): await self.get_access_token() url = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send' headers = { 'x-acs-dingtalk-access-token': self.access_token, 'Content-Type': 'application/json', } data = { 'robotCode': self.robot_code, 'openConversationId': target_id, 'msgKey': 'sampleText', 'msgParam': json.dumps({'content': content}), } try: async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, json=data) if response.status_code == 200: return except Exception: await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}') raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}') async def create_and_card( self, temp_card_id: str, incoming_message: dingtalk_stream.ChatbotMessage, quote_origin: bool = False, card_auto_layout: bool = False, ): card_data = {} card_data['config'] = json.dumps({'autoLayout': card_auto_layout}) card_data['content'] = '' card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message) # print(card_instance) # 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards card_instance_id = await card_instance.async_create_and_deliver_card( temp_card_id, card_data, ) return card_instance, card_instance_id async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool): content_key = 'content' try: await card_instance.async_streaming( card_instance_id, content_key=content_key, content_value=content, append=False, finished=is_final, failed=False, ) except Exception as e: self.logger.exception(e) await card_instance.async_streaming( card_instance_id, content_key=content_key, content_value='', append=False, finished=is_final, failed=True, ) async def start(self): """启动 WebSocket 连接,监听消息""" self._stopped = False self.client.pre_start() while not self._stopped: try: connection = self.client.open_connection() if not connection: if self.logger: await self.logger.error('DingTalk: open connection failed') await asyncio.sleep(10) continue uri = '%s?ticket=%s' % (connection['endpoint'], urllib.parse.quote_plus(connection['ticket'])) async with websockets.connect(uri) as websocket: self.client.websocket = websocket keepalive_task = asyncio.create_task(self._keepalive(websocket)) try: async for raw_message in websocket: if self._stopped: break json_message = json.loads(raw_message) asyncio.create_task(self.client.background_task(json_message)) finally: keepalive_task.cancel() try: await keepalive_task except asyncio.CancelledError: pass except asyncio.CancelledError: # Properly exit when task is cancelled break except websockets.exceptions.ConnectionClosedError as e: if self._stopped: break if self.logger: await self.logger.error(f'DingTalk: connection closed, reconnecting... error={e}') await asyncio.sleep(5) continue except Exception as e: if self._stopped: break if self.logger: await self.logger.error(f'DingTalk: unknown exception, reconnecting... error={e}') await asyncio.sleep(3) continue async def _keepalive(self, ws, ping_interval=60): """Keep WebSocket connection alive""" while not self._stopped: await asyncio.sleep(ping_interval) try: await ws.ping() except websockets.exceptions.ConnectionClosed: break async def stop(self): """停止 WebSocket 连接""" self._stopped = True # Close WebSocket connection if exists if self.client.websocket: try: await self.client.websocket.close() except Exception: pass # Clear message handlers to prevent stale callbacks self._message_handlers = {'example': []} ================================================ FILE: src/langbot/libs/dingtalk_api/dingtalkevent.py ================================================ from typing import Dict, Any, Optional import dingtalk_stream # type: ignore class DingTalkEvent(dict): @staticmethod def from_payload(payload: Dict[str, Any]) -> Optional['DingTalkEvent']: try: event = DingTalkEvent(payload) return event except KeyError: return None @property def content(self): return self.get('Content', '') @property def rich_content(self): return self.get('Rich_Content', '') @property def incoming_message(self) -> Optional['dingtalk_stream.chatbot.ChatbotMessage']: return self.get('IncomingMessage') @property def type(self): return self.get('Type', '') @property def picture(self): return self.get('Picture', '') @property def audio(self): return self.get('Audio', '') @property def file(self): return self.get('File', '') @property def name(self): return self.get('Name', '') @property def conversation(self): return self.get('conversation_type', '') def __getattr__(self, key: str) -> Optional[Any]: """ 允许通过属性访问数据中的任意字段。 Args: key (str): 字段名。 Returns: Optional[Any]: 字段值。 """ return self.get(key) def __setattr__(self, key: str, value: Any) -> None: """ 允许通过属性设置数据中的任意字段。 Args: key (str): 字段名。 value (Any): 字段值。 """ self[key] = value def __repr__(self) -> str: """ 生成事件对象的字符串表示。 Returns: str: 字符串表示。 """ return f'' ================================================ FILE: src/langbot/libs/official_account_api/__init__.py ================================================ ================================================ FILE: src/langbot/libs/official_account_api/api.py ================================================ # 微信公众号的加解密算法与企业微信一样,所以直接使用企业微信的加解密算法文件 import time import traceback from langbot.libs.wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt import xml.etree.ElementTree as ET from quart import Quart, request import hashlib from typing import Callable from langbot.libs.official_account_api.oaevent import OAEvent import asyncio xml_template = """ {create_time} """ class OAClient: def __init__( self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None, unified_mode: bool = False, api_base_url: str = 'https://api.weixin.qq.com', ): self.token = token self.aes = EncodingAESKey self.appid = AppID self.appsecret = Appsecret self.base_url = api_base_url self.access_token = '' self.unified_mode = unified_mode self.app = Quart(__name__) # 只有在非统一模式下才注册独立路由 if not self.unified_mode: self.app.add_url_rule( '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'], ) self._message_handlers = { 'example': [], } self.access_token_expiry_time = None self.msg_id_map = {} self.generated_content = {} self.logger = logger async def handle_callback_request(self): """处理回调请求(独立端口模式,使用全局 request)。""" return await self._handle_callback_internal(request) async def handle_unified_webhook(self, req): """处理回调请求(统一 webhook 模式,显式传递 request)。 Args: req: Quart Request 对象 Returns: 响应数据 """ return await self._handle_callback_internal(req) async def _handle_callback_internal(self, req): """处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 Args: req: Quart Request 对象 """ try: # 每隔100毫秒查询是否生成ai回答 start_time = time.time() signature = req.args.get('signature', '') timestamp = req.args.get('timestamp', '') nonce = req.args.get('nonce', '') echostr = req.args.get('echostr', '') msg_signature = req.args.get('msg_signature', '') if msg_signature is None: await self.logger.error('msg_signature不在请求体中') raise Exception('msg_signature不在请求体中') if req.method == 'GET': # 校验签名 check_str = ''.join(sorted([self.token, timestamp, nonce])) check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest() if check_signature == signature: return echostr # 验证成功返回echostr else: await self.logger.error('拒绝请求') raise Exception('拒绝请求') elif req.method == 'POST': encryt_msg = await req.data wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce) xml_msg = xml_msg.decode('utf-8') if ret != 0: await self.logger.error('消息解密失败') raise Exception('消息解密失败') message_data = await self.get_message(xml_msg) if message_data: event = OAEvent.from_payload(message_data) if event: await self._handle_message(event) root = ET.fromstring(xml_msg) from_user = root.find('FromUserName').text # 发送者 to_user = root.find('ToUserName').text # 机器人 timeout = 4.80 interval = 0.1 while True: content = self.generated_content.pop(message_data['MsgId'], None) if content: response_xml = xml_template.format( to_user=from_user, from_user=to_user, create_time=int(time.time()), content=content, ) return response_xml if time.time() - start_time >= timeout: break await asyncio.sleep(interval) if self.msg_id_map.get(message_data['MsgId'], 1) == 3: # response_xml = xml_template.format( # to_user=from_user, # from_user=to_user, # create_time=int(time.time()), # content = "请求失效:暂不支持公众号超过15秒的请求,如有需求,请联系 LangBot 团队。" # ) print('请求失效:暂不支持公众号超过15秒的请求,如有需求,请联系 LangBot 团队。') return '' except Exception: await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}') traceback.print_exc() async def get_message(self, xml_msg: str): root = ET.fromstring(xml_msg) message_data = { 'ToUserName': root.find('ToUserName').text, 'FromUserName': root.find('FromUserName').text, 'CreateTime': int(root.find('CreateTime').text), 'MsgType': root.find('MsgType').text, 'Content': root.find('Content').text if root.find('Content') is not None else None, 'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None, } return message_data async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 """ await self.app.run_task(host=host, port=port, *args, **kwargs) def on_message(self, msg_type: str): """ 注册消息类型处理器。 """ def decorator(func: Callable[[OAEvent], None]): if msg_type not in self._message_handlers: self._message_handlers[msg_type] = [] self._message_handlers[msg_type].append(func) return func return decorator async def _handle_message(self, event: OAEvent): """ 处理消息事件。 """ message_id = event.message_id if message_id in self.msg_id_map.keys(): self.msg_id_map[message_id] += 1 return self.msg_id_map[message_id] = 1 msg_type = event.type if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) async def set_message(self, msg_id: int, content: str): self.generated_content[msg_id] = content class OAClientForLongerResponse: def __init__( self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, LoadingMessage: str, logger: None, unified_mode: bool = False, api_base_url: str = 'https://api.weixin.qq.com', ): self.token = token self.aes = EncodingAESKey self.appid = AppID self.appsecret = Appsecret self.base_url = api_base_url self.access_token = '' self.unified_mode = unified_mode self.app = Quart(__name__) # 只有在非统一模式下才注册独立路由 if not self.unified_mode: self.app.add_url_rule( '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'], ) self._message_handlers = { 'example': [], } self.access_token_expiry_time = None self.loading_message = LoadingMessage self.msg_queue = {} self.user_msg_queue = {} self.logger = logger async def handle_callback_request(self): """处理回调请求(独立端口模式,使用全局 request)。""" return await self._handle_callback_internal(request) async def handle_unified_webhook(self, req): """处理回调请求(统一 webhook 模式,显式传递 request)。 Args: req: Quart Request 对象 Returns: 响应数据 """ return await self._handle_callback_internal(req) async def _handle_callback_internal(self, req): """处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 Args: req: Quart Request 对象 """ try: signature = req.args.get('signature', '') timestamp = req.args.get('timestamp', '') nonce = req.args.get('nonce', '') echostr = req.args.get('echostr', '') msg_signature = req.args.get('msg_signature', '') if msg_signature is None: await self.logger.error('msg_signature不在请求体中') raise Exception('msg_signature不在请求体中') if req.method == 'GET': check_str = ''.join(sorted([self.token, timestamp, nonce])) check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest() return echostr if check_signature == signature else '拒绝请求' elif req.method == 'POST': encryt_msg = await req.data wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce) xml_msg = xml_msg.decode('utf-8') if ret != 0: await self.logger.error('消息解密失败') raise Exception('消息解密失败') # 解析 XML root = ET.fromstring(xml_msg) from_user = root.find('FromUserName').text to_user = root.find('ToUserName').text if self.msg_queue.get(from_user) and self.msg_queue[from_user][0]['content']: queue_top = self.msg_queue[from_user].pop(0) queue_content = queue_top['content'] # 弹出用户消息 if self.user_msg_queue.get(from_user) and self.user_msg_queue[from_user]: self.user_msg_queue[from_user].pop(0) response_xml = xml_template.format( to_user=from_user, from_user=to_user, create_time=int(time.time()), content=queue_content, ) return response_xml else: response_xml = xml_template.format( to_user=from_user, from_user=to_user, create_time=int(time.time()), content=self.loading_message, ) if self.user_msg_queue.get(from_user) and self.user_msg_queue[from_user][0]['content']: return response_xml else: message_data = await self.get_message(xml_msg) if message_data: event = OAEvent.from_payload(message_data) if event: self.user_msg_queue.setdefault(from_user, []).append( { 'content': event.message, } ) await self._handle_message(event) return response_xml except Exception: await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}') traceback.print_exc() async def get_message(self, xml_msg: str): root = ET.fromstring(xml_msg) message_data = { 'ToUserName': root.find('ToUserName').text, 'FromUserName': root.find('FromUserName').text, 'CreateTime': int(root.find('CreateTime').text), 'MsgType': root.find('MsgType').text, 'Content': root.find('Content').text if root.find('Content') is not None else None, 'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None, } return message_data async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 """ await self.app.run_task(host=host, port=port, *args, **kwargs) def on_message(self, msg_type: str): """ 注册消息类型处理器。 """ def decorator(func: Callable[[OAEvent], None]): if msg_type not in self._message_handlers: self._message_handlers[msg_type] = [] self._message_handlers[msg_type].append(func) return func return decorator async def _handle_message(self, event: OAEvent): """ 处理消息事件。 """ msg_type = event.type if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) async def set_message(self, from_user: int, message_id: int, content: str): if from_user not in self.msg_queue: self.msg_queue[from_user] = [] self.msg_queue[from_user].append( { 'msg_id': message_id, 'content': content, } ) ================================================ FILE: src/langbot/libs/official_account_api/oaevent.py ================================================ from typing import Dict, Any, Optional class OAEvent(dict): """ 封装从微信公众号收到的事件数据对象(字典),提供属性以获取其中的字段。 除 `type` 和 `detail_type` 属性对于任何事件都有效外,其它属性是否存在(若不存在则返回 `None`)依事件类型不同而不同。 """ @staticmethod def from_payload(payload: Dict[str, Any]) -> Optional['OAEvent']: """ 从微信公众号事件数据构造 `WecomEvent` 对象。 Args: payload (Dict[str, Any]): 解密后的微信事件数据。 Returns: Optional[OAEvent]: 如果事件数据合法,则返回 OAEvent 对象;否则返回 None。 """ try: event = OAEvent(payload) _ = event.type, event.detail_type # 确保必须字段存在 return event except KeyError: return None @property def type(self) -> str: """ 事件类型,例如 "message"、"event"、"text" 等。 Returns: str: 事件类型。 """ return self.get('MsgType', '') @property def picurl(self) -> str: """ 图片链接 """ return self.get('PicUrl', '') @property def detail_type(self) -> str: """ 事件详细类型,依 `type` 的不同而不同。例如: - 消息事件: "text", "image", "voice", 等 - 事件通知: "subscribe", "unsubscribe", "click", 等 Returns: str: 事件详细类型。 """ if self.type == 'event': return self.get('Event', '') return self.type @property def name(self) -> str: """ 事件名,对于消息事件是 `type.detail_type`,对于其他事件是 `event_type`。 Returns: str: 事件名。 """ return f'{self.type}.{self.detail_type}' @property def user_id(self) -> Optional[str]: """ 发送方账号 """ return self.get('FromUserName') @property def receiver_id(self) -> Optional[str]: """ 接收者 ID,例如机器人自身的公众号微信 ID。 Returns: Optional[str]: 接收者 ID。 """ return self.get('ToUserName') @property def message_id(self) -> Optional[str]: """ 消息 ID,仅在消息类型事件中存在。 Returns: Optional[str]: 消息 ID。 """ return self.get('MsgId') @property def message(self) -> Optional[str]: """ 消息内容,仅在消息类型事件中存在。 Returns: Optional[str]: 消息内容。 """ return self.get('Content') @property def media_id(self) -> Optional[str]: """ 媒体文件 ID,仅在图片、语音等消息类型中存在。 Returns: Optional[str]: 媒体文件 ID。 """ return self.get('MediaId') @property def timestamp(self) -> Optional[int]: """ 事件发生的时间戳。 Returns: Optional[int]: 时间戳。 """ return self.get('CreateTime') @property def event_key(self) -> Optional[str]: """ 事件的 Key 值,例如点击菜单时的 `EventKey`。 Returns: Optional[str]: 事件 Key。 """ return self.get('EventKey') def __getattr__(self, key: str) -> Optional[Any]: """ 允许通过属性访问数据中的任意字段。 Args: key (str): 字段名。 Returns: Optional[Any]: 字段值。 """ return self.get(key) def __setattr__(self, key: str, value: Any) -> None: """ 允许通过属性设置数据中的任意字段。 Args: key (str): 字段名。 value (Any): 字段值。 """ self[key] = value def __repr__(self) -> str: """ 生成事件对象的字符串表示。 Returns: str: 字符串表示。 """ return f'' ================================================ FILE: src/langbot/libs/qq_official_api/__init__.py ================================================ ================================================ FILE: src/langbot/libs/qq_official_api/api.py ================================================ import time from quart import request import httpx from quart import Quart from typing import Callable, Dict, Any import langbot_plugin.api.entities.builtin.platform.events as platform_events from .qqofficialevent import QQOfficialEvent import json import traceback from cryptography.hazmat.primitives.asymmetric import ed25519 class QQOfficialClient: def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False): self.unified_mode = unified_mode self.app = Quart(__name__) # 只有在非统一模式下才注册独立路由 if not self.unified_mode: self.app.add_url_rule( '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'], ) self.secret = secret self.token = token self.app_id = app_id self._message_handlers = {} self.base_url = 'https://api.sgroup.qq.com' self.access_token = '' self.access_token_expiry_time = None self.logger = logger async def check_access_token(self): """检查access_token是否存在""" if not self.access_token or await self.is_token_expired(): return False return bool(self.access_token and self.access_token.strip()) async def get_access_token(self): """获取access_token""" url = 'https://bots.qq.com/app/getAppAccessToken' async with httpx.AsyncClient() as client: params = { 'appId': self.app_id, 'clientSecret': self.secret, } headers = { 'content-type': 'application/json', } try: response = await client.post(url, json=params, headers=headers) if response.status_code == 200: response_data = response.json() access_token = response_data.get('access_token') expires_in = int(response_data.get('expires_in', 7200)) self.access_token_expiry_time = time.time() + expires_in - 60 if access_token: self.access_token = access_token except Exception as e: await self.logger.error(f'获取access_token失败: {response_data}') raise Exception(f'获取access_token失败: {e}') async def handle_callback_request(self): """处理回调请求(独立端口模式,使用全局 request)""" return await self._handle_callback_internal(request) async def handle_unified_webhook(self, req): """处理回调请求(统一 webhook 模式,显式传递 request)。 Args: req: Quart Request 对象 Returns: 响应数据 """ return await self._handle_callback_internal(req) async def _handle_callback_internal(self, req): """处理回调请求的内部实现。 Args: req: Quart Request 对象 """ try: body = await req.get_data() print(f'[QQ Official] Received request, body length: {len(body)}') if not body or len(body) == 0: print('[QQ Official] Received empty body, might be health check or GET request') return {'code': 0, 'message': 'ok'}, 200 payload = json.loads(body) if payload.get('op') == 13: validation_data = payload.get('d') if not validation_data: return {'error': "missing 'd' field"}, 400 response = await self.verify(validation_data) return response, 200 if payload.get('op') == 0: message_data = await self.get_message(payload) if message_data: event = QQOfficialEvent.from_payload(message_data) await self._handle_message(event) return {'code': 0, 'message': 'success'} except Exception as e: print(f'[QQ Official] ERROR: {traceback.format_exc()}') await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') return {'error': str(e)}, 400 async def run_task(self, host: str, port: int, *args, **kwargs): """启动 Quart 应用""" await self.app.run_task(host=host, port=port, *args, **kwargs) def on_message(self, msg_type: str): """注册消息类型处理器""" def decorator(func: Callable[[platform_events.Event], None]): if msg_type not in self._message_handlers: self._message_handlers[msg_type] = [] self._message_handlers[msg_type].append(func) return func return decorator async def _handle_message(self, event: QQOfficialEvent): """处理消息事件""" msg_type = event.t if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) async def get_message(self, msg: dict) -> Dict[str, Any]: """获取消息""" message_data = { 't': msg.get('t', {}), 'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}), 'timestamp': msg.get('d', {}).get('timestamp', {}), 'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}), 'content': msg.get('d', {}).get('content', {}), 'd_id': msg.get('d', {}).get('id', {}), 'id': msg.get('id', {}), 'channel_id': msg.get('d', {}).get('channel_id', {}), 'username': msg.get('d', {}).get('author', {}).get('username', {}), 'guild_id': msg.get('d', {}).get('guild_id', {}), 'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}), 'group_openid': msg.get('d', {}).get('group_openid', {}), } attachments = msg.get('d', {}).get('attachments', []) image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)] image_attachments_type = [ attachment['content_type'] for attachment in attachments if await self.is_image(attachment) ] if image_attachments: message_data['image_attachments'] = image_attachments[0] message_data['content_type'] = image_attachments_type[0] else: message_data['image_attachments'] = None return message_data async def is_image(self, attachment: dict) -> bool: """判断是否为图片附件""" content_type = attachment.get('content_type', '') return content_type.startswith('image/') async def send_private_text_msg(self, user_openid: str, content: str, msg_id: str): """发送私聊消息""" if not await self.check_access_token(): await self.get_access_token() url = self.base_url + '/v2/users/' + user_openid + '/messages' async with httpx.AsyncClient() as client: headers = { 'Authorization': f'QQBot {self.access_token}', 'Content-Type': 'application/json', } data = { 'content': content, 'msg_type': 0, 'msg_id': msg_id, } response = await client.post(url, headers=headers, json=data) response_data = response.json() if response.status_code == 200: return else: await self.logger.error(f'发送私聊消息失败: {response_data}') raise ValueError(response) async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str): """发送群聊消息""" if not await self.check_access_token(): await self.get_access_token() url = self.base_url + '/v2/groups/' + group_openid + '/messages' async with httpx.AsyncClient() as client: headers = { 'Authorization': f'QQBot {self.access_token}', 'Content-Type': 'application/json', } data = { 'content': content, 'msg_type': 0, 'msg_id': msg_id, } response = await client.post(url, headers=headers, json=data) if response.status_code == 200: return else: await self.logger.error(f'发送群聊消息失败:{response.json()}') raise Exception(response.read().decode()) async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str): """发送频道群聊消息""" if not await self.check_access_token(): await self.get_access_token() url = self.base_url + '/channels/' + channel_id + '/messages' async with httpx.AsyncClient() as client: headers = { 'Authorization': f'QQBot {self.access_token}', 'Content-Type': 'application/json', } params = { 'content': content, 'msg_type': 0, 'msg_id': msg_id, } response = await client.post(url, headers=headers, json=params) if response.status_code == 200: return True else: await self.logger.error(f'发送频道群聊消息失败: {response.json()}') raise Exception(response) async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str): """发送频道私聊消息""" if not await self.check_access_token(): await self.get_access_token() url = self.base_url + '/dms/' + guild_id + '/messages' async with httpx.AsyncClient() as client: headers = { 'Authorization': f'QQBot {self.access_token}', 'Content-Type': 'application/json', } params = { 'content': content, 'msg_type': 0, 'msg_id': msg_id, } response = await client.post(url, headers=headers, json=params) if response.status_code == 200: return True else: await self.logger.error(f'发送频道私聊消息失败: {response.json()}') raise Exception(response) async def is_token_expired(self): """检查token是否过期""" if self.access_token_expiry_time is None: return True return time.time() > self.access_token_expiry_time async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes: seed = bot_secret while len(seed) < target_size: seed *= 2 return seed[:target_size].encode('utf-8') async def verify(self, validation_payload: dict): seed = await self.repeat_seed(self.secret) private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed) event_ts = validation_payload.get('event_ts', '') plain_token = validation_payload.get('plain_token', '') msg = event_ts + plain_token # sign signature = private_key.sign(msg.encode()).hex() response = { 'plain_token': plain_token, 'signature': signature, } return response ================================================ FILE: src/langbot/libs/qq_official_api/qqofficialevent.py ================================================ from typing import Dict, Any, Optional class QQOfficialEvent(dict): @staticmethod def from_payload(payload: Dict[str, Any]) -> Optional['QQOfficialEvent']: try: event = QQOfficialEvent(payload) return event except KeyError: return None @property def t(self) -> str: """ 事件类型 """ return self.get('t', '') @property def user_openid(self) -> str: """ 用户openid """ return self.get('user_openid', {}) @property def timestamp(self) -> str: """ 时间戳 """ return self.get('timestamp', {}) @property def d_author_id(self) -> str: """ 作者id """ return self.get('id', {}) @property def content(self) -> str: """ 内容 """ return self.get('content', '') @property def d_id(self) -> str: """ d_id """ return self.get('d_id', {}) @property def id(self) -> str: """ 消息id,msg_id """ return self.get('id', {}) @property def channel_id(self) -> str: """ 频道id """ return self.get('channel_id', {}) @property def username(self) -> str: """ 用户名 """ return self.get('username', {}) @property def guild_id(self) -> str: """ 频道id """ return self.get('guild_id', {}) @property def member_openid(self) -> str: """ 成员openid """ return self.get('openid', {}) @property def attachments(self) -> str: """ 附件url """ url = self.get('image_attachments', '') if url and not url.startswith('https://'): url = 'https://' + url return url @property def group_openid(self) -> str: """ 群组id """ return self.get('group_openid', {}) @property def content_type(self) -> str: """ 文件类型 """ return self.get('content_type', '') ================================================ FILE: src/langbot/libs/slack_api/__init__.py ================================================ ================================================ FILE: src/langbot/libs/slack_api/api.py ================================================ import json import traceback from quart import Quart, jsonify, request from slack_sdk.web.async_client import AsyncWebClient from .slackevent import SlackEvent from typing import Callable import langbot_plugin.api.entities.builtin.platform.events as platform_events class SlackClient: def __init__(self, bot_token: str, signing_secret: str, logger: None, unified_mode: bool = False): self.bot_token = bot_token self.signing_secret = signing_secret self.unified_mode = unified_mode self.app = Quart(__name__) self.client = AsyncWebClient(self.bot_token) # 只有在非统一模式下才注册独立路由 if not self.unified_mode: self.app.add_url_rule( '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'] ) self._message_handlers = { 'example': [], } self.bot_user_id = None # 避免机器人回复自己的消息 self.logger = logger async def handle_callback_request(self): """处理回调请求(独立端口模式,使用全局 request)""" return await self._handle_callback_internal(request) async def handle_unified_webhook(self, req): """处理回调请求(统一 webhook 模式,显式传递 request)。 Args: req: Quart Request 对象 Returns: 响应数据 """ return await self._handle_callback_internal(req) async def _handle_callback_internal(self, req): """处理回调请求的内部实现。 Args: req: Quart Request 对象 """ try: body = await req.get_data() data = json.loads(body) if 'type' in data: if data['type'] == 'url_verification': return data['challenge'] bot_user_id = data.get('event', {}).get('bot_id', '') if self.bot_user_id and bot_user_id == self.bot_user_id: return jsonify({'status': 'ok'}) # 处理私信 if data and data.get('event', {}).get('channel_type') in ['im']: event = SlackEvent.from_payload(data) await self._handle_message(event) return jsonify({'status': 'ok'}) # 处理群聊 if data.get('event', {}).get('type') == 'app_mention': data.setdefault('event', {})['channel_type'] = 'channel' event = SlackEvent.from_payload(data) await self._handle_message(event) return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'}) except Exception as e: await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') raise (e) async def _handle_message(self, event: SlackEvent): """ 处理消息事件。 """ msg_type = event.type if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) def on_message(self, msg_type: str): """注册消息类型处理器""" def decorator(func: Callable[[platform_events.Event], None]): if msg_type not in self._message_handlers: self._message_handlers[msg_type] = [] self._message_handlers[msg_type].append(func) return func return decorator async def send_message_to_channel(self, text: str, channel_id: str): try: response = await self.client.chat_postMessage(channel=channel_id, text=text) if self.bot_user_id is None and response.get('ok'): self.bot_user_id = response['message']['bot_id'] return except Exception as e: await self.logger.error(f'Error in send_message: {e}') raise e async def send_message_to_one(self, text: str, user_id: str): try: response = await self.client.chat_postMessage(channel='@' + user_id, text=text) if self.bot_user_id is None and response.get('ok'): self.bot_user_id = response['message']['bot_id'] return except Exception as e: await self.logger.error(f'Error in send_message: {traceback.format_exc()}') raise e async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 """ await self.app.run_task(host=host, port=port, *args, **kwargs) ================================================ FILE: src/langbot/libs/slack_api/slackevent.py ================================================ from typing import Dict, Any, Optional class SlackEvent(dict): @staticmethod def from_payload(payload: Dict[str, Any]) -> Optional['SlackEvent']: try: event = SlackEvent(payload) return event except KeyError: return None @property def text(self) -> str: if self.get('event', {}).get('channel_type') == 'im': blocks = self.get('event', {}).get('blocks', []) if not blocks: return '' elements = blocks[0].get('elements', []) if not elements: return '' elements = elements[0].get('elements', []) text = '' for el in elements: if el.get('type') == 'text': text += el.get('text', '') elif el.get('type') == 'link': text += el.get('url', '') return text if self.get('event', {}).get('channel_type') == 'channel': message_text = '' for block in self.get('event', {}).get('blocks', []): if block.get('type') == 'rich_text': for element in block.get('elements', []): if element.get('type') == 'rich_text_section': parts = [] for el in element.get('elements', []): if el.get('type') == 'text': parts.append(el['text']) elif el.get('type') == 'link': parts.append(el['url']) message_text = ''.join(parts) return message_text @property def user_id(self) -> Optional[str]: return self.get('event', {}).get('user', '') @property def channel_id(self) -> Optional[str]: return self.get('event', {}).get('channel', '') @property def type(self) -> str: """message对应私聊,app_mention对应频道at""" return self.get('event', {}).get('channel_type', '') @property def message_id(self) -> str: return self.get('event_id', '') @property def pic_url(self) -> str: """提取 Slack 事件中的图片 URL""" files = self.get('event', {}).get('files', []) if files: return files[0].get('url_private', '') return None @property def sender_name(self) -> str: return self.get('event', {}).get('user', '') def __getattr__(self, key: str) -> Optional[Any]: return self.get(key) def __setattr__(self, key: str, value: Any) -> None: self[key] = value def __repr__(self) -> str: return f'' ================================================ FILE: src/langbot/libs/wechatpad_api/LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: src/langbot/libs/wechatpad_api/README.md ================================================ # wechatpad-python ## 此项目时准备对接wechatpadpro 的pythonsdk ## 未完工接口 * 关于好友的接口 * 关于群管理的接口 * 关于下载的接口 * 关于用户的部分接口 * 关于消息的部分接口 * 关于支付的 * 关于朋友圈的 * 关于标签的 * 关于收藏的 * 暂时只写了一部分接口 ## 已完工接口 1. 获取普通token 2. 登录二维码(只是返回数据,暂时还未打印二维码) 3. 获取登录状态 4. 唤醒登录 5. 退出登录 6. 获取用户信息 7. 获取用户二维码 8. 上传用户头像 9. 获取设备信息 10. 发送文本消息 11. 发送图片消息 12. 发送语音消息 13. 发送app消息 14. 发送emoji消息 15. 发送名片消息 16. 撤回消息 ================================================ FILE: src/langbot/libs/wechatpad_api/__init__.py ================================================ from .client import WeChatPadClient as WeChatPadClient ================================================ FILE: src/langbot/libs/wechatpad_api/api/__init__.py ================================================ ================================================ FILE: src/langbot/libs/wechatpad_api/api/chatroom.py ================================================ from langbot.libs.wechatpad_api.util.http_util import post_json class ChatRoomApi: def __init__(self, base_url, token): self.base_url = base_url self.token = token def get_chatroom_member_detail(self, chatroom_name): params = {'ChatRoomName': chatroom_name} url = self.base_url + '/group/GetChatroomMemberDetail' return post_json(url, token=self.token, data=params) ================================================ FILE: src/langbot/libs/wechatpad_api/api/downloadpai.py ================================================ from langbot.libs.wechatpad_api.util.http_util import post_json import httpx import base64 class DownloadApi: def __init__(self, base_url, token): self.base_url = base_url self.token = token def send_download(self, aeskey, file_type, file_url): json_data = {'AesKey': aeskey, 'FileType': file_type, 'FileURL': file_url} url = self.base_url + '/message/SendCdnDownload' return post_json(url, token=self.token, data=json_data) def get_msg_voice(self, buf_id, length, new_msgid): json_data = {'Bufid': buf_id, 'Length': length, 'NewMsgId': new_msgid, 'ToUserName': ''} url = self.base_url + '/message/GetMsgVoice' return post_json(url, token=self.token, data=json_data) async def download_url_to_base64(self, download_url): async with httpx.AsyncClient() as client: response = await client.get(download_url) if response.status_code == 200: file_bytes = response.content base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式 return base64_str else: raise Exception('获取文件失败') ================================================ FILE: src/langbot/libs/wechatpad_api/api/friend.py ================================================ class FriendApi: """联系人API类,处理所有与联系人相关的操作""" def __init__(self, base_url: str, token: str): self.base_url = base_url self.token = token ================================================ FILE: src/langbot/libs/wechatpad_api/api/login.py ================================================ from langbot.libs.wechatpad_api.util.http_util import post_json, get_json class LoginApi: def __init__(self, base_url: str, token: str = None, admin_key: str = None): """ Args: base_url: 原始路径 token: token admin_key: 管理员key """ self.base_url = base_url self.token = token # self.admin_key = admin_key def get_token(self, admin_key, day: int = 365): # 获取普通token url = f'{self.base_url}/admin/GenAuthKey1' json_data = {'Count': 1, 'Days': day} return post_json(base_url=url, token=admin_key, data=json_data) def get_login_qr(self, Proxy: str = ''): """ Args: Proxy:异地使用时代理 Returns:json数据 """ """ { "Code": 200, "Data": { "Key": "3141312", "QrCodeUrl": "https://1231x/g6bMlv2dX8zwNbqE6-Zs", "Txt": "建议返回data=之后内容自定义生成二维码", "baseResp": { "ret": 0, "errMsg": {} } }, "Text": "" } """ # 获取登录二维码 url = f'{self.base_url}/login/GetLoginQrCodeNew' check = False if Proxy != '': check = True json_data = {'Check': check, 'Proxy': Proxy} return post_json(base_url=url, token=self.token, data=json_data) def get_login_status(self): # 获取登录状态 url = f'{self.base_url}/login/GetLoginStatus' return get_json(base_url=url, token=self.token) def logout(self): # 退出登录 url = f'{self.base_url}/login/LogOut' return post_json(base_url=url, token=self.token) def wake_up_login(self, Proxy: str = ''): # 唤醒登录 url = f'{self.base_url}/login/WakeUpLogin' check = False if Proxy != '': check = True json_data = {'Check': check, 'Proxy': ''} return post_json(base_url=url, token=self.token, data=json_data) def login(self, admin_key): login_status = self.get_login_status() if login_status['Code'] == 300 and login_status['Text'] == '你已退出微信': print('token已经失效,重新获取') token_data = self.get_token(admin_key) self.token = token_data['Data'][0] ================================================ FILE: src/langbot/libs/wechatpad_api/api/message.py ================================================ from langbot.libs.wechatpad_api.util.http_util import post_json class MessageApi: def __init__(self, base_url, token): self.base_url = base_url self.token = token def post_text(self, to_wxid, content, ats: list = []): """ Args: app_id: 微信id to_wxid: 发送方的微信id content: 内容 ats: at Returns: """ url = self.base_url + '/message/SendTextMessage' """发送文字消息""" json_data = { 'MsgItem': [ {'AtWxIDList': ats, 'ImageContent': '', 'MsgType': 0, 'TextContent': content, 'ToUserName': to_wxid} ] } return post_json(base_url=url, token=self.token, data=json_data) def post_image(self, to_wxid, img_url, ats: list = []): """发送图片消息""" # 这里好像可以尝试发送多个暂时未测试 json_data = { 'MsgItem': [ {'AtWxIDList': ats, 'ImageContent': img_url, 'MsgType': 0, 'TextContent': '', 'ToUserName': to_wxid} ] } url = self.base_url + '/message/SendImageMessage' return post_json(base_url=url, token=self.token, data=json_data) def post_voice(self, to_wxid, voice_data, voice_forma, voice_duration): """发送语音消息""" json_data = { 'ToUserName': to_wxid, 'VoiceData': voice_data, 'VoiceFormat': voice_forma, 'VoiceSecond': voice_duration, } url = self.base_url + '/message/SendVoice' return post_json(base_url=url, token=self.token, data=json_data) def post_name_card(self, alias, to_wxid, nick_name, name_card_wxid, flag): """发送名片消息""" param = { 'CardAlias': alias, 'CardFlag': flag, 'CardNickName': nick_name, 'CardWxId': name_card_wxid, 'ToUserName': to_wxid, } url = f'{self.base_url}/message/ShareCardMessage' return post_json(base_url=url, token=self.token, data=param) def post_emoji(self, to_wxid, emoji_md5, emoji_size: int = 0): """发送emoji消息""" json_data = {'EmojiList': [{'EmojiMd5': emoji_md5, 'EmojiSize': emoji_size, 'ToUserName': to_wxid}]} url = f'{self.base_url}/message/SendEmojiMessage' return post_json(base_url=url, token=self.token, data=json_data) def post_app_msg(self, to_wxid, xml_data, contenttype: int = 0): """发送appmsg消息""" json_data = {'AppList': [{'ContentType': contenttype, 'ContentXML': xml_data, 'ToUserName': to_wxid}]} url = f'{self.base_url}/message/SendAppMessage' return post_json(base_url=url, token=self.token, data=json_data) def revoke_msg(self, to_wxid, msg_id, new_msg_id, create_time): """撤回消息""" param = {'ClientMsgId': msg_id, 'CreateTime': create_time, 'NewMsgId': new_msg_id, 'ToUserName': to_wxid} url = f'{self.base_url}/message/RevokeMsg' return post_json(base_url=url, token=self.token, data=param) ================================================ FILE: src/langbot/libs/wechatpad_api/api/user.py ================================================ from langbot.libs.wechatpad_api.util.http_util import post_json, async_request, get_json class UserApi: def __init__(self, base_url, token): self.base_url = base_url self.token = token def get_profile(self): """获取个人资料""" url = f'{self.base_url}/user/GetProfile' return get_json(base_url=url, token=self.token) def get_qr_code(self, recover: bool = True, style: int = 8): """获取自己的二维码""" param = {'Recover': recover, 'Style': style} url = f'{self.base_url}/user/GetMyQRCode' return post_json(base_url=url, token=self.token, data=param) def get_safety_info(self): """获取设备记录""" url = f'{self.base_url}/equipment/GetSafetyInfo' return post_json(base_url=url, token=self.token) async def update_head_img(self, head_img_base64): """修改头像""" param = {'Base64': head_img_base64} url = f'{self.base_url}/user/UploadHeadImage' return await async_request(base_url=url, token_key=self.token, json=param) ================================================ FILE: src/langbot/libs/wechatpad_api/client.py ================================================ from langbot.libs.wechatpad_api.api.login import LoginApi from langbot.libs.wechatpad_api.api.friend import FriendApi from langbot.libs.wechatpad_api.api.message import MessageApi from langbot.libs.wechatpad_api.api.user import UserApi from langbot.libs.wechatpad_api.api.downloadpai import DownloadApi from langbot.libs.wechatpad_api.api.chatroom import ChatRoomApi class WeChatPadClient: def __init__(self, base_url, token, logger=None): self._login_api = LoginApi(base_url, token) self._friend_api = FriendApi(base_url, token) self._message_api = MessageApi(base_url, token) self._user_api = UserApi(base_url, token) self._download_api = DownloadApi(base_url, token) self._chatroom_api = ChatRoomApi(base_url, token) self.logger = logger def get_token(self, admin_key, day: int): """获取token""" return self._login_api.get_token(admin_key, day) def get_login_qr(self, Proxy: str = ''): """登录二维码""" return self._login_api.get_login_qr(Proxy=Proxy) def awaken_login(self, Proxy: str = ''): """唤醒登录""" return self._login_api.wake_up_login(Proxy=Proxy) def log_out(self): """退出登录""" return self._login_api.logout() def get_login_status(self): """获取登录状态""" return self._login_api.get_login_status() def send_text_message(self, to_wxid, message, ats: list = []): """发送文本消息""" return self._message_api.post_text(to_wxid, message, ats) def send_image_message(self, to_wxid, img_url, ats: list = []): """发送图片消息""" return self._message_api.post_image(to_wxid, img_url, ats) def send_voice_message(self, to_wxid, voice_data, voice_forma, voice_duration): """发送音频消息""" return self._message_api.post_voice(to_wxid, voice_data, voice_forma, voice_duration) def send_app_message(self, to_wxid, app_message, type): """发送app消息""" return self._message_api.post_app_msg(to_wxid, app_message, type) def send_emoji_message(self, to_wxid, emoji_md5, emoji_size): """发送emoji消息""" return self._message_api.post_emoji(to_wxid, emoji_md5, emoji_size) def revoke_msg(self, to_wxid, msg_id, new_msg_id, create_time): """撤回消息""" return self._message_api.revoke_msg(to_wxid, msg_id, new_msg_id, create_time) def get_profile(self): """获取用户信息""" return self._user_api.get_profile() def get_qr_code(self, recover: bool = True, style: int = 8): """获取用户二维码""" return self._user_api.get_qr_code(recover=recover, style=style) def get_safety_info(self): """获取设备信息""" return self._user_api.get_safety_info() def update_head_img(self, head_img_base64): """上传用户头像""" return self._user_api.update_head_img(head_img_base64) def cdn_download(self, aeskey, file_type, file_url): """cdn下载""" return self._download_api.send_download(aeskey, file_type, file_url) def get_msg_voice(self, buf_id, length, msgid): """下载语音""" return self._download_api.get_msg_voice(buf_id, length, msgid) async def download_base64(self, url): return await self._download_api.download_url_to_base64(download_url=url) def get_chatroom_member_detail(self, chatroom_name): """查看群成员详情""" return self._chatroom_api.get_chatroom_member_detail(chatroom_name) ================================================ FILE: src/langbot/libs/wechatpad_api/util/__init__.py ================================================ ================================================ FILE: src/langbot/libs/wechatpad_api/util/http_util.py ================================================ import requests from langbot.pkg.utils import httpclient def post_json(base_url, token, data=None): headers = {'Content-Type': 'application/json'} url = base_url + f'?key={token}' try: response = requests.post(url, json=data, headers=headers, timeout=60) response.raise_for_status() result = response.json() if result: return result else: raise RuntimeError(response.text) except Exception as e: print(f'http请求失败, url={url}, exception={e}') raise RuntimeError(str(e)) def get_json(base_url, token): headers = {'Content-Type': 'application/json'} url = base_url + f'?key={token}' try: response = requests.get(url, headers=headers, timeout=60) response.raise_for_status() result = response.json() if result: return result else: raise RuntimeError(response.text) except Exception as e: print(f'http请求失败, url={url}, exception={e}') raise RuntimeError(str(e)) async def async_request( base_url: str, token_key: str, method: str = 'POST', params: dict = None, # headers: dict = None, data: dict = None, json: dict = None, ): """ 通用异步请求函数 :param base_url: 请求URL :param token_key: 请求token :param method: HTTP方法 (GET, POST, PUT, DELETE等) :param params: URL查询参数 # :param headers: 请求头 :param data: 表单数据 :param json: JSON数据 :return: 响应文本 """ headers = {'Content-Type': 'application/json'} url = f'{base_url}?key={token_key}' session = httpclient.get_session() async with session.request( method=method, url=url, params=params, headers=headers, data=data, json=json ) as response: response.raise_for_status() # 如果状态码不是200,抛出异常 result = await response.json() # print(result) return result # if result.get('Code') == 200: # # return await result # else: # raise RuntimeError("请求失败",response.text) ================================================ FILE: src/langbot/libs/wechatpad_api/util/terminal_printer.py ================================================ import qrcode def print_green(text): print(f'\033[32m{text}\033[0m') def print_yellow(text): print(f'\033[33m{text}\033[0m') def print_red(text): print(f'\033[31m{text}\033[0m') def make_and_print_qr(url): """生成并打印二维码 Args: url: 需要生成二维码的URL字符串 Returns: None 功能: 1. 在终端打印二维码的ASCII图形 2. 同时提供在线二维码生成链接作为备选 """ print_green('请扫描下方二维码登录') qr = qrcode.QRCode() qr.add_data(url) qr.make() qr.print_ascii(invert=True) print_green(f'也可以访问下方链接获取二维码:\nhttps://api.qrserver.com/v1/create-qr-code/?data={url}') ================================================ FILE: src/langbot/libs/wecom_ai_bot_api/WXBizMsgCrypt3.py ================================================ #!/usr/bin/env python # -*- encoding:utf-8 -*- """对企业微信发送给企业后台的消息加解密示例代码. @copyright: Copyright (c) 1998-2014 Tencent Inc. """ # ------------------------------------------------------------------------ import logging import base64 import random import hashlib import time import struct from Crypto.Cipher import AES import xml.etree.cElementTree as ET import socket from langbot.libs.wecom_ai_bot_api import ierror """ Crypto.Cipher包已不再维护,开发者可以通过以下命令下载安装最新版的加解密工具包 pip install pycryptodome """ class FormatException(Exception): pass def throw_exception(message, exception_class=FormatException): """my define raise exception function""" raise exception_class(message) class SHA1: """计算企业微信的消息签名接口""" def getSHA1(self, token, timestamp, nonce, encrypt): """用SHA1算法生成安全签名 @param token: 票据 @param timestamp: 时间戳 @param encrypt: 密文 @param nonce: 随机字符串 @return: 安全签名 """ try: sortlist = [token, timestamp, nonce, encrypt] sortlist.sort() sha = hashlib.sha1() sha.update(''.join(sortlist).encode()) return ierror.WXBizMsgCrypt_OK, sha.hexdigest() except Exception as e: logger = logging.getLogger() logger.error(e) return ierror.WXBizMsgCrypt_ComputeSignature_Error, None class XMLParse: """提供提取消息格式中的密文及生成回复消息格式的接口""" # xml消息模板 AES_TEXT_RESPONSE_TEMPLATE = """ %(timestamp)s """ def extract(self, xmltext): """提取出xml数据包中的加密消息 @param xmltext: 待提取的xml字符串 @return: 提取出的加密消息字符串 """ try: xml_tree = ET.fromstring(xmltext) encrypt = xml_tree.find('Encrypt') return ierror.WXBizMsgCrypt_OK, encrypt.text except Exception as e: logger = logging.getLogger() logger.error(e) return ierror.WXBizMsgCrypt_ParseXml_Error, None def generate(self, encrypt, signature, timestamp, nonce): """生成xml消息 @param encrypt: 加密后的消息密文 @param signature: 安全签名 @param timestamp: 时间戳 @param nonce: 随机字符串 @return: 生成的xml字符串 """ resp_dict = { 'msg_encrypt': encrypt, 'msg_signaturet': signature, 'timestamp': timestamp, 'nonce': nonce, } resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict return resp_xml class PKCS7Encoder: """提供基于PKCS7算法的加解密接口""" block_size = 32 def encode(self, text): """对需要加密的明文进行填充补位 @param text: 需要进行填充补位操作的明文 @return: 补齐明文字符串 """ text_length = len(text) # 计算需要填充的位数 amount_to_pad = self.block_size - (text_length % self.block_size) if amount_to_pad == 0: amount_to_pad = self.block_size # 获得补位所用的字符 pad = chr(amount_to_pad) return text + (pad * amount_to_pad).encode() def decode(self, decrypted): """删除解密后明文的补位字符 @param decrypted: 解密后的明文 @return: 删除补位字符后的明文 """ pad = ord(decrypted[-1]) if pad < 1 or pad > 32: pad = 0 return decrypted[:-pad] class Prpcrypt(object): """提供接收和推送给企业微信消息的加解密接口""" def __init__(self, key): # self.key = base64.b64decode(key+"=") self.key = key # 设置加解密模式为AES的CBC模式 self.mode = AES.MODE_CBC def encrypt(self, text, receiveid): """对明文进行加密 @param text: 需要加密的明文 @return: 加密得到的字符串 """ # 16位随机字符串添加到明文开头 text = text.encode() text = self.get_random_str() + struct.pack('I', socket.htonl(len(text))) + text + receiveid.encode() # 使用自定义的填充方式对明文进行补位填充 pkcs7 = PKCS7Encoder() text = pkcs7.encode(text) # 加密 cryptor = AES.new(self.key, self.mode, self.key[:16]) try: ciphertext = cryptor.encrypt(text) # 使用BASE64对加密后的字符串进行编码 return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext) except Exception as e: logger = logging.getLogger() logger.error(e) return ierror.WXBizMsgCrypt_EncryptAES_Error, None def decrypt(self, text, receiveid): """对解密后的明文进行补位删除 @param text: 密文 @return: 删除填充补位后的明文 """ try: cryptor = AES.new(self.key, self.mode, self.key[:16]) # 使用BASE64对密文进行解码,然后AES-CBC解密 plain_text = cryptor.decrypt(base64.b64decode(text)) except Exception as e: logger = logging.getLogger() logger.error(e) return ierror.WXBizMsgCrypt_DecryptAES_Error, None try: pad = plain_text[-1] # 去掉补位字符串 # pkcs7 = PKCS7Encoder() # plain_text = pkcs7.encode(plain_text) # 去除16位随机字符串 content = plain_text[16:-pad] xml_len = socket.ntohl(struct.unpack('I', content[:4])[0]) xml_content = content[4 : xml_len + 4] from_receiveid = content[xml_len + 4 :] except Exception as e: logger = logging.getLogger() logger.error(e) return ierror.WXBizMsgCrypt_IllegalBuffer, None if from_receiveid.decode('utf8') != receiveid: return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None return 0, xml_content def get_random_str(self): """随机生成16位字符串 @return: 16位字符串 """ return str(random.randint(1000000000000000, 9999999999999999)).encode() class WXBizMsgCrypt(object): # 构造函数 def __init__(self, sToken, sEncodingAESKey, sReceiveId): try: self.key = base64.b64decode(sEncodingAESKey + '=') assert len(self.key) == 32 except Exception: throw_exception('[error]: EncodingAESKey unvalid !', FormatException) # return ierror.WXBizMsgCrypt_IllegalAesKey,None self.m_sToken = sToken self.m_sReceiveId = sReceiveId # 验证URL # @param sMsgSignature: 签名串,对应URL参数的msg_signature # @param sTimeStamp: 时间戳,对应URL参数的timestamp # @param sNonce: 随机串,对应URL参数的nonce # @param sEchoStr: 随机串,对应URL参数的echostr # @param sReplyEchoStr: 解密之后的echostr,当return返回0时有效 # @return:成功0,失败返回对应的错误码 def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr): sha1 = SHA1() ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr) if ret != 0: return ret, None if not signature == sMsgSignature: return ierror.WXBizMsgCrypt_ValidateSignature_Error, None pc = Prpcrypt(self.key) ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId) return ret, sReplyEchoStr def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None): # 将企业回复用户的消息加密打包 # @param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串 # @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间 # @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce # sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串, # return:成功0,sEncryptMsg,失败返回对应的错误码None pc = Prpcrypt(self.key) ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId) encrypt = encrypt.decode('utf8') if ret != 0: return ret, None if timestamp is None: timestamp = str(int(time.time())) # 生成安全签名 sha1 = SHA1() ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt) if ret != 0: return ret, None xmlParse = XMLParse() return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce) def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce): # 检验消息的真实性,并且获取解密后的明文 # @param sMsgSignature: 签名串,对应URL参数的msg_signature # @param sTimeStamp: 时间戳,对应URL参数的timestamp # @param sNonce: 随机串,对应URL参数的nonce # @param sPostData: 密文,对应POST请求的数据 # xml_content: 解密后的原文,当return返回0时有效 # @return: 成功0,失败返回对应的错误码 # 验证安全签名 xmlParse = XMLParse() ret, encrypt = xmlParse.extract(sPostData) if ret != 0: return ret, None sha1 = SHA1() ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt) if ret != 0: return ret, None if not signature == sMsgSignature: return ierror.WXBizMsgCrypt_ValidateSignature_Error, None pc = Prpcrypt(self.key) ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId) return ret, xml_content ================================================ FILE: src/langbot/libs/wecom_ai_bot_api/api.py ================================================ import asyncio import base64 import json import time import traceback import uuid import xml.etree.ElementTree as ET from dataclasses import dataclass, field from typing import Any, Callable, Optional from urllib.parse import unquote import httpx from Crypto.Cipher import AES from quart import Quart, request, Response, jsonify from langbot.libs.wecom_ai_bot_api import wecombotevent from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt from langbot.pkg.platform.logger import EventLogger @dataclass class StreamChunk: """描述单次推送给企业微信的流式片段。""" # 需要返回给企业微信的文本内容 content: str # 标记是否为最终片段,对应企业微信协议里的 finish 字段 is_final: bool = False # 预留额外元信息,未来支持多模态扩展时可使用 meta: dict[str, Any] = field(default_factory=dict) @dataclass class StreamSession: """维护一次企业微信流式会话的上下文。""" # 企业微信要求的 stream_id,用于标识后续刷新请求 stream_id: str # 原始消息的 msgid,便于与流水线消息对应 msg_id: str # 群聊会话标识(单聊时为空) chat_id: Optional[str] # 触发消息的发送者 user_id: Optional[str] # 会话创建时间 created_at: float = field(default_factory=time.time) # 最近一次被访问的时间,cleanup 依据该值判断过期 last_access: float = field(default_factory=time.time) # 将流水线增量结果缓存到队列,刷新请求逐条消费 queue: asyncio.Queue = field(default_factory=asyncio.Queue) # 是否已经完成(收到最终片段) finished: bool = False # 缓存最近一次片段,处理重试或超时兜底 last_chunk: Optional[StreamChunk] = None class StreamSessionManager: """管理 stream 会话的生命周期,并负责队列的生产消费。""" def __init__(self, logger: EventLogger, ttl: int = 60) -> None: self.logger = logger self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射 self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话 def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]: if not msg_id: return None return self._msg_index.get(msg_id) def get_session(self, stream_id: str) -> Optional[StreamSession]: return self._sessions.get(stream_id) def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]: """根据企业微信回调创建或获取会话。 Args: msg_json: 企业微信解密后的回调 JSON。 Returns: Tuple[StreamSession, bool]: `StreamSession` 为会话实例,`bool` 指示是否为新建会话。 Example: 在首次回调中调用,得到 `is_new=True` 后再触发流水线。 """ msg_id = msg_json.get('msgid', '') if msg_id and msg_id in self._msg_index: stream_id = self._msg_index[msg_id] session = self._sessions.get(stream_id) if session: session.last_access = time.time() return session, False stream_id = str(uuid.uuid4()) session = StreamSession( stream_id=stream_id, msg_id=msg_id, chat_id=msg_json.get('chatid'), user_id=msg_json.get('from', {}).get('userid'), ) if msg_id: self._msg_index[msg_id] = stream_id self._sessions[stream_id] = session return session, True async def publish(self, stream_id: str, chunk: StreamChunk) -> bool: """向 stream 队列写入新的增量片段。 Args: stream_id: 企业微信分配的流式会话 ID。 chunk: 待发送的增量片段。 Returns: bool: 当流式队列存在并成功入队时返回 True。 Example: 在收到模型增量后调用 `await manager.publish('sid', StreamChunk('hello'))`。 """ session = self._sessions.get(stream_id) if not session: return False session.last_access = time.time() session.last_chunk = chunk try: session.queue.put_nowait(chunk) except asyncio.QueueFull: # 默认无界队列,此处兜底防御 await session.queue.put(chunk) if chunk.is_final: session.finished = True return True async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[StreamChunk]: """从队列中取出一个片段,若超时返回 None。 Args: stream_id: 企业微信流式会话 ID。 timeout: 取片段的最长等待时间(秒)。 Returns: Optional[StreamChunk]: 成功时返回片段,超时或会话不存在时返回 None。 Example: 企业微信刷新到达时调用,若队列有数据则立即返回 `StreamChunk`。 """ session = self._sessions.get(stream_id) if not session: return None session.last_access = time.time() try: chunk = await asyncio.wait_for(session.queue.get(), timeout) session.last_access = time.time() if chunk.is_final: session.finished = True return chunk except asyncio.TimeoutError: if session.finished and session.last_chunk: return session.last_chunk return None def mark_finished(self, stream_id: str) -> None: session = self._sessions.get(stream_id) if session: session.finished = True session.last_access = time.time() def cleanup(self) -> None: """定期清理过期会话,防止队列与映射无上限累积。""" now = time.time() expired: list[str] = [] for stream_id, session in self._sessions.items(): if now - session.last_access > self.ttl: expired.append(stream_id) for stream_id in expired: session = self._sessions.pop(stream_id, None) if not session: continue msg_id = session.msg_id if msg_id and self._msg_index.get(msg_id) == stream_id: self._msg_index.pop(msg_id, None) async def download_encrypted_file(download_url: str, encoding_aes_key: str, logger: EventLogger) -> Optional[str]: """Download an AES-encrypted file from WeChat Work and return as data URI. Args: download_url: The encrypted file download URL. encoding_aes_key: The AES key used for decryption (base64-encoded, without trailing '='). logger: Logger instance. Returns: A data URI string (e.g. 'data:image/jpeg;base64,...') or None on failure. """ if not download_url: return None async with httpx.AsyncClient() as client: response = await client.get(download_url) if response.status_code != 200: await logger.error(f'failed to get file: {response.text}') return None encrypted_bytes = response.content aes_key = base64.b64decode(encoding_aes_key + '=') iv = aes_key[:16] cipher = AES.new(aes_key, AES.MODE_CBC, iv) decrypted = cipher.decrypt(encrypted_bytes) pad_len = decrypted[-1] decrypted = decrypted[:-pad_len] if decrypted.startswith(b'\xff\xd8'): mime_type = 'image/jpeg' elif decrypted.startswith(b'\x89PNG'): mime_type = 'image/png' elif decrypted.startswith((b'GIF87a', b'GIF89a')): mime_type = 'image/gif' elif decrypted.startswith(b'BM'): mime_type = 'image/bmp' elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'): mime_type = 'image/tiff' else: mime_type = 'application/octet-stream' base64_str = base64.b64encode(decrypted).decode('utf-8') return f'data:{mime_type};base64,{base64_str}' async def parse_wecom_bot_message( msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger ) -> dict[str, Any]: """Parse a decrypted WeChat Work AI Bot message JSON into a unified message dict. This is the shared message parsing logic used by both webhook and WebSocket modes. Args: msg_json: The decrypted message JSON from WeChat Work. encoding_aes_key: AES key for file decryption. logger: Logger instance. Returns: A dict suitable for constructing a WecomBotEvent. """ message_data: dict[str, Any] = {} msg_type = msg_json.get('msgtype', '') if msg_type: message_data['msgtype'] = msg_type if msg_json.get('chattype', '') == 'single': message_data['type'] = 'single' elif msg_json.get('chattype', '') == 'group': message_data['type'] = 'group' max_inline_file_size = 5 * 1024 * 1024 async def _safe_download(url: str): if not url: return None return await download_encrypted_file(url, encoding_aes_key, logger) if msg_type == 'text': message_data['content'] = msg_json.get('text', {}).get('content') elif msg_type == 'markdown': message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get( 'content', '' ) elif msg_type == 'image': picurl = msg_json.get('image', {}).get('url', '') base64_data = await _safe_download(picurl) if base64_data: message_data['picurl'] = base64_data message_data['images'] = [base64_data] elif msg_type == 'voice': voice_info = msg_json.get('voice', {}) or {} download_url = voice_info.get('url') message_data['voice'] = { 'url': download_url, 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), 'filesize': voice_info.get('filesize') or voice_info.get('size'), 'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'), } if voice_info.get('content'): message_data['content'] = voice_info.get('content') if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size: voice_base64 = await _safe_download(download_url) if voice_base64: message_data['voice']['base64'] = voice_base64 elif msg_type == 'video': video_info = msg_json.get('video', {}) or {} download_url = video_info.get('url') video_data = { 'url': download_url, 'filesize': video_info.get('filesize') or video_info.get('size'), 'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'), 'md5sum': video_info.get('md5sum') or video_info.get('md5'), 'filename': video_info.get('filename') or video_info.get('name'), } if (video_data.get('filesize') or 0) <= max_inline_file_size: video_base64 = await _safe_download(download_url) if video_base64: video_data['base64'] = video_base64 message_data['video'] = video_data elif msg_type == 'file': file_info = msg_json.get('file', {}) or {} download_url = file_info.get('url') or file_info.get('fileurl') file_data = { 'filename': file_info.get('filename') or file_info.get('name'), 'filesize': file_info.get('filesize') or file_info.get('size'), 'md5sum': file_info.get('md5sum') or file_info.get('md5'), 'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'), 'download_url': download_url, 'extra': file_info, } if (file_data.get('filesize') or 0) <= max_inline_file_size: file_base64 = await _safe_download(download_url) if file_base64: file_data['base64'] = file_base64 message_data['file'] = file_data elif msg_type == 'link': message_data['link'] = msg_json.get('link', {}) if not message_data.get('content'): title = message_data['link'].get('title', '') desc = message_data['link'].get('description') or message_data['link'].get('digest', '') message_data['content'] = '\n'.join(filter(None, [title, desc])) elif msg_type == 'mixed': items = msg_json.get('mixed', {}).get('msg_item', []) texts = [] images = [] files = [] voices = [] videos = [] links = [] for item in items: item_type = item.get('msgtype') if item_type == 'text': texts.append(item.get('text', {}).get('content', '')) elif item_type == 'image': img_url = item.get('image', {}).get('url') base64_data = await _safe_download(img_url) if base64_data: images.append(base64_data) elif item_type == 'file': file_info = item.get('file', {}) or {} download_url = file_info.get('url') or file_info.get('fileurl') file_data = { 'filename': file_info.get('filename') or file_info.get('name'), 'filesize': file_info.get('filesize') or file_info.get('size'), 'md5sum': file_info.get('md5sum') or file_info.get('md5'), 'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'), 'download_url': download_url, 'extra': file_info, } if (file_data.get('filesize') or 0) <= max_inline_file_size: file_base64 = await _safe_download(download_url) if file_base64: file_data['base64'] = file_base64 files.append(file_data) elif item_type == 'voice': voice_info = item.get('voice', {}) or {} download_url = voice_info.get('url') voice_data = { 'url': download_url, 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), 'filesize': voice_info.get('filesize') or voice_info.get('size'), 'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'), } if voice_info.get('content'): texts.append(voice_info.get('content')) if (voice_data.get('filesize') or 0) <= max_inline_file_size: voice_base64 = await _safe_download(download_url) if voice_base64: voice_data['base64'] = voice_base64 voices.append(voice_data) elif item_type == 'video': video_info = item.get('video', {}) or {} download_url = video_info.get('url') video_data = { 'url': download_url, 'filesize': video_info.get('filesize') or video_info.get('size'), 'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'), 'md5sum': video_info.get('md5sum') or video_info.get('md5'), 'filename': video_info.get('filename') or video_info.get('name'), } if (video_data.get('filesize') or 0) <= max_inline_file_size: video_base64 = await _safe_download(download_url) if video_base64: video_data['base64'] = video_base64 videos.append(video_data) elif item_type == 'link': links.append(item.get('link', {})) if texts: message_data['content'] = ' '.join(texts) if images: message_data['images'] = images message_data['picurl'] = images[0] if files: message_data['files'] = files message_data['file'] = files[0] if voices: message_data['voices'] = voices message_data['voice'] = voices[0] if videos: message_data['videos'] = videos message_data['video'] = videos[0] if links: message_data['link'] = links[0] if items: message_data['attachments'] = items else: message_data['raw_msg'] = msg_json from_info = msg_json.get('from', {}) message_data['userid'] = from_info.get('userid', '') message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '') if msg_json.get('chattype', '') == 'group': message_data['chatid'] = msg_json.get('chatid', '') message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '') message_data['msgid'] = msg_json.get('msgid', '') if msg_json.get('aibotid'): message_data['aibotid'] = msg_json.get('aibotid', '') return message_data class WecomBotClient: def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False): """企业微信智能机器人客户端。 Args: Token: 企业微信回调验证使用的 token。 EnCodingAESKey: 企业微信消息加解密密钥。 Corpid: 企业 ID。 logger: 日志记录器。 unified_mode: 是否使用统一 webhook 模式(默认 False)。 Example: >>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger) """ self.Token = Token self.EnCodingAESKey = EnCodingAESKey self.Corpid = Corpid self.ReceiveId = '' self.unified_mode = unified_mode self.app = Quart(__name__) # 只有在非统一模式下才注册独立路由 if not self.unified_mode: self.app.add_url_rule( '/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET'] ) self._message_handlers = { 'example': [], } self.logger = logger self.generated_content: dict[str, str] = {} self.msg_id_map: dict[str, int] = {} self.stream_sessions = StreamSessionManager(logger=logger) self.stream_poll_timeout = 0.5 @staticmethod def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]: """按照企业微信协议拼装返回报文。 Args: stream_id: 企业微信会话 ID。 content: 推送的文本内容。 finish: 是否为最终片段。 Returns: dict[str, Any]: 可直接加密返回的 payload。 Example: 组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。 """ return { 'msgtype': 'stream', 'stream': { 'id': stream_id, 'finish': finish, 'content': content, }, } async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]: """对响应进行加密封装并返回给企业微信。 Args: payload: 待加密的响应内容。 nonce: 企业微信回调参数中的 nonce。 Returns: Tuple[Response, int]: Quart Response 对象及状态码。 Example: 在首包或刷新场景中调用以生成加密响应。 """ reply_plain_str = json.dumps(payload, ensure_ascii=False) reply_timestamp = str(int(time.time())) ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp) if ret != 0: await self.logger.error(f'加密失败: {ret}') return jsonify({'error': 'encrypt_failed'}), 500 root = ET.fromstring(encrypt_text) encrypt = root.find('Encrypt').text resp = { 'encrypt': encrypt, } return jsonify(resp), 200 async def _dispatch_event(self, event: wecombotevent.WecomBotEvent) -> None: """异步触发流水线处理,避免阻塞首包响应。 Args: event: 由企业微信消息转换的内部事件对象。 """ try: await self._handle_message(event) except Exception: await self.logger.error(traceback.format_exc()) async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """处理企业微信首次推送的消息,返回 stream_id 并开启流水线。 Args: msg_json: 解密后的企业微信消息 JSON。 nonce: 企业微信回调参数 nonce。 Returns: Tuple[Response, int]: Quart Response 及状态码。 Example: 首次回调时调用,立即返回带 `stream_id` 的响应。 """ session, is_new = self.stream_sessions.create_or_get(msg_json) message_data = await self.get_message(msg_json) if message_data: message_data['stream_id'] = session.stream_id try: event = wecombotevent.WecomBotEvent(message_data) except Exception: await self.logger.error(traceback.format_exc()) else: if is_new: asyncio.create_task(self._dispatch_event(event)) payload = self._build_stream_payload(session.stream_id, '', False) return await self._encrypt_and_reply(payload, nonce) async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """处理企业微信的流式刷新请求,按需返回增量片段。 Args: msg_json: 解密后的企业微信刷新请求。 nonce: 企业微信回调参数 nonce。 Returns: Tuple[Response, int]: Quart Response 及状态码。 Example: 在刷新请求中调用,按需返回增量片段。 """ stream_info = msg_json.get('stream', {}) stream_id = stream_info.get('id', '') if not stream_id: await self.logger.error('刷新请求缺少 stream.id') return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce) session = self.stream_sessions.get_session(stream_id) chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout) if not chunk: cached_content = None if session and session.msg_id: cached_content = self.generated_content.pop(session.msg_id, None) if cached_content is not None: chunk = StreamChunk(content=cached_content, is_final=True) else: payload = self._build_stream_payload(stream_id, '', False) return await self._encrypt_and_reply(payload, nonce) payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final) if chunk.is_final: self.stream_sessions.mark_finished(stream_id) return await self._encrypt_and_reply(payload, nonce) async def handle_callback_request(self): """企业微信回调入口(独立端口模式,使用全局 request)。 Returns: Quart Response: 根据请求类型返回验证、首包或刷新结果。 Example: 作为 Quart 路由处理函数直接注册并使用。 """ return await self._handle_callback_internal(request) async def handle_unified_webhook(self, req): """处理回调请求(统一 webhook 模式,显式传递 request)。 Args: req: Quart Request 对象 Returns: 响应数据 """ return await self._handle_callback_internal(req) async def _handle_callback_internal(self, req): """处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 Args: req: Quart Request 对象 """ try: self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '') if req.method == 'GET': return await self._handle_get_callback(req) if req.method == 'POST': return await self._handle_post_callback(req) return Response('', status=405) except Exception: await self.logger.error(traceback.format_exc()) return Response('Internal Server Error', status=500) async def _handle_get_callback(self, req) -> tuple[Response, int] | Response: """处理企业微信的 GET 验证请求。""" msg_signature = unquote(req.args.get('msg_signature', '')) timestamp = unquote(req.args.get('timestamp', '')) nonce = unquote(req.args.get('nonce', '')) echostr = unquote(req.args.get('echostr', '')) if not all([msg_signature, timestamp, nonce, echostr]): await self.logger.error('请求参数缺失') return Response('缺少参数', status=400) ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) if ret != 0: await self.logger.error('验证URL失败') return Response('验证失败', status=403) return Response(decrypted_str, mimetype='text/plain') async def _handle_post_callback(self, req) -> tuple[Response, int] | Response: """处理企业微信的 POST 回调请求。""" self.stream_sessions.cleanup() msg_signature = unquote(req.args.get('msg_signature', '')) timestamp = unquote(req.args.get('timestamp', '')) nonce = unquote(req.args.get('nonce', '')) encrypted_json = await req.get_json() encrypted_msg = (encrypted_json or {}).get('encrypt', '') if not encrypted_msg: await self.logger.error("请求体中缺少 'encrypt' 字段") return Response('Bad Request', status=400) xml_post_data = f'' ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) if ret != 0: await self.logger.error('解密失败') return Response('解密失败', status=400) msg_json = json.loads(decrypted_xml) if msg_json.get('msgtype') == 'stream': return await self._handle_post_followup_response(msg_json, nonce) return await self._handle_post_initial_response(msg_json, nonce) async def get_message(self, msg_json): return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger) async def _handle_message(self, event: wecombotevent.WecomBotEvent): """ 处理消息事件。 """ try: message_id = event.message_id if message_id in self.msg_id_map.keys(): self.msg_id_map[message_id] += 1 return self.msg_id_map[message_id] = 1 msg_type = event.type if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) except Exception: print(traceback.format_exc()) async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: """将流水线片段推送到 stream 会话。 Args: msg_id: 原始企业微信消息 ID。 content: 模型产生的片段内容。 is_final: 是否为最终片段。 Returns: bool: 当成功写入流式队列时返回 True。 Example: 在流水线 `reply_message_chunk` 中调用,将增量推送至企业微信。 """ # 根据 msg_id 找到对应 stream 会话,如果不存在说明当前消息非流式 stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id) if not stream_id: return False chunk = StreamChunk(content=content, is_final=is_final) await self.stream_sessions.publish(stream_id, chunk) if is_final: self.stream_sessions.mark_finished(stream_id) return True async def set_message(self, msg_id: str, content: str): """兼容旧逻辑:若无法流式返回则缓存最终结果。 Args: msg_id: 企业微信消息 ID。 content: 最终回复的文本内容。 Example: 在非流式场景下缓存最终结果以备刷新时返回。 """ handled = await self.push_stream_chunk(msg_id, content, is_final=True) if not handled: self.generated_content[msg_id] = content def on_message(self, msg_type: str): def decorator(func: Callable[[wecombotevent.WecomBotEvent], None]): if msg_type not in self._message_handlers: self._message_handlers[msg_type] = [] self._message_handlers[msg_type].append(func) return func return decorator async def download_url_to_base64(self, download_url, encoding_aes_key): return await download_encrypted_file(download_url, encoding_aes_key, self.logger) async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 """ await self.app.run_task(host=host, port=port, *args, **kwargs) ================================================ FILE: src/langbot/libs/wecom_ai_bot_api/ierror.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- ######################################################################### # Author: jonyqin # Created Time: Thu 11 Sep 2014 01:53:58 PM CST # File Name: ierror.py # Description:定义错误码含义 ######################################################################### WXBizMsgCrypt_OK = 0 WXBizMsgCrypt_ValidateSignature_Error = -40001 WXBizMsgCrypt_ParseXml_Error = -40002 WXBizMsgCrypt_ComputeSignature_Error = -40003 WXBizMsgCrypt_IllegalAesKey = -40004 WXBizMsgCrypt_ValidateCorpid_Error = -40005 WXBizMsgCrypt_EncryptAES_Error = -40006 WXBizMsgCrypt_DecryptAES_Error = -40007 WXBizMsgCrypt_IllegalBuffer = -40008 WXBizMsgCrypt_EncodeBase64_Error = -40009 WXBizMsgCrypt_DecodeBase64_Error = -40010 WXBizMsgCrypt_GenReturnXml_Error = -40011 ================================================ FILE: src/langbot/libs/wecom_ai_bot_api/wecombotevent.py ================================================ from typing import Dict, Any, Optional class WecomBotEvent(dict): @staticmethod def from_payload(payload: Dict[str, Any]) -> Optional['WecomBotEvent']: try: event = WecomBotEvent(payload) return event except KeyError: return None @property def type(self) -> str: """ 事件类型 """ return self.get('type', '') @property def msgtype(self) -> str: """ 消息 msgtype """ return self.get('msgtype', '') @property def userid(self) -> str: """ 用户id """ return self.get('from', {}).get('userid', '') or self.get('userid', '') @property def username(self) -> str: """ 用户名称 """ return ( self.get('username', '') or self.get('from', {}).get('alias', '') or self.get('from', {}).get('name', '') or self.userid ) @property def chatname(self) -> str: """ 群组名称 """ return self.get('chatname', '') or str(self.chatid) @property def content(self) -> str: """ 内容 """ return self.get('content', '') @property def picurl(self) -> str: """ 图片url """ return self.get('picurl', '') @property def images(self): """ 图片列表(兼容 mixed) """ return self.get('images', []) @property def file(self): """ 文件信息 """ return self.get('file', {}) @property def voice(self): """ 语音信息 """ return self.get('voice', {}) @property def video(self): """ 视频信息 """ return self.get('video', {}) @property def link(self): """ 链接消息信息 """ return self.get('link', {}) @property def location(self): """ 位置信息 """ return self.get('location', {}) @property def attachments(self): """ 原始 mixed 中的附件项 """ return self.get('attachments', []) @property def chatid(self) -> str: """ 群组id """ return self.get('chatid', {}) @property def message_id(self) -> str: """ 消息id """ return self.get('msgid', '') @property def ai_bot_id(self) -> str: """ AI Bot ID """ return self.get('aibotid', '') ================================================ FILE: src/langbot/libs/wecom_ai_bot_api/ws_client.py ================================================ """WeChat Work AI Bot WebSocket long connection client. Implements the WebSocket protocol for receiving messages and sending replies via a persistent connection to wss://openws.work.weixin.qq.com, as an alternative to the HTTP callback (webhook) mode. Protocol reference: https://developer.work.weixin.qq.com/document/path/101463 Official Node.js SDK: https://github.com/WecomTeam/aibot-node-sdk """ from __future__ import annotations import asyncio import json import secrets import time import traceback from typing import Any, Callable, Optional import aiohttp from langbot.libs.wecom_ai_bot_api import wecombotevent from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message from langbot.pkg.platform.logger import EventLogger DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com' # WebSocket frame command constants CMD_SUBSCRIBE = 'aibot_subscribe' CMD_HEARTBEAT = 'ping' CMD_MSG_CALLBACK = 'aibot_msg_callback' CMD_EVENT_CALLBACK = 'aibot_event_callback' CMD_RESPOND_MSG = 'aibot_respond_msg' CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg' CMD_RESPOND_UPDATE = 'aibot_respond_update_msg' CMD_SEND_MSG = 'aibot_send_msg' def _generate_req_id(prefix: str) -> str: """Generate a unique request ID in the format: {prefix}_{timestamp}_{random}.""" ts = int(time.time() * 1000) rand = secrets.token_hex(4) return f'{prefix}_{ts}_{rand}' class WecomBotWsClient: """WeChat Work AI Bot WebSocket long connection client. Provides message receiving, streaming reply, proactive message sending, and event callback handling over a persistent WebSocket connection. """ def __init__( self, bot_id: str, secret: str, logger: EventLogger, encoding_aes_key: str = '', ws_url: str = DEFAULT_WS_URL, heartbeat_interval: float = 30.0, max_reconnect_attempts: int = -1, reconnect_base_delay: float = 1.0, reconnect_max_delay: float = 30.0, ): self.bot_id = bot_id self.secret = secret self.logger = logger self.encoding_aes_key = encoding_aes_key self.ws_url = ws_url self.heartbeat_interval = heartbeat_interval self.max_reconnect_attempts = max_reconnect_attempts self.reconnect_base_delay = reconnect_base_delay self.reconnect_max_delay = reconnect_max_delay self._ws: Optional[aiohttp.ClientWebSocketResponse] = None self._session: Optional[aiohttp.ClientSession] = None self._running = False self._heartbeat_task: Optional[asyncio.Task] = None self._missed_pong_count = 0 self._max_missed_pong = 2 self._reconnect_attempts = 0 # Message handler registry (same pattern as WecomBotClient) self._message_handlers: dict[str, list[Callable]] = {} # Message deduplication self._msg_id_map: dict[str, int] = {} # Pending ACK futures: req_id -> Future[dict] self._pending_acks: dict[str, asyncio.Future] = {} # Per-req_id serial reply queues self._reply_queues: dict[str, asyncio.Queue] = {} self._reply_workers: dict[str, asyncio.Task] = {} self._reply_ack_timeout = 5.0 # Stream ID tracking for WebSocket mode self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id # Dedup: skip sending when content hasn't changed self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent # ── Public API ────────────────────────────────────────────────── async def connect(self): """Connect to WebSocket server with automatic reconnection. This method blocks until disconnect() is called or max reconnect attempts are exhausted. """ self._running = True self._reconnect_attempts = 0 while self._running: try: await self._connect_once() except Exception: if not self._running: break await self.logger.error(f'WebSocket connection error: {traceback.format_exc()}') if not self._running: break # Reconnect with exponential backoff if self.max_reconnect_attempts != -1 and self._reconnect_attempts >= self.max_reconnect_attempts: await self.logger.error(f'Max reconnect attempts reached ({self.max_reconnect_attempts}), giving up') break self._reconnect_attempts += 1 delay = min( self.reconnect_base_delay * (2 ** (self._reconnect_attempts - 1)), self.reconnect_max_delay, ) await self.logger.info(f'Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempts})...') await asyncio.sleep(delay) async def disconnect(self): """Gracefully disconnect from the WebSocket server.""" self._running = False if self._heartbeat_task and not self._heartbeat_task.done(): self._heartbeat_task.cancel() for task in self._reply_workers.values(): if not task.done(): task.cancel() if self._ws and not self._ws.closed: await self._ws.close() self._ws = None if self._session and not self._session.closed: await self._session.close() self._session = None def on_message(self, msg_type: str) -> Callable: """Decorator to register a message handler. Same interface as WecomBotClient.on_message for compatibility. Args: msg_type: 'single', 'group', or specific message type. """ def decorator(func: Callable[[wecombotevent.WecomBotEvent], Any]): if msg_type not in self._message_handlers: self._message_handlers[msg_type] = [] self._message_handlers[msg_type].append(func) return func return decorator async def reply_stream( self, req_id: str, stream_id: str, content: str, finish: bool = False, ) -> Optional[dict]: """Send a streaming reply frame. Args: req_id: The req_id from the original message frame (must be passed through). stream_id: The stream ID for this streaming session. content: The content to send (supports Markdown). finish: Whether this is the final chunk. Returns: The ACK frame dict, or None on failure. """ body = { 'msgtype': 'stream', 'stream': { 'id': stream_id, 'finish': finish, 'content': content, }, } return await self._send_reply(req_id, body) async def reply_text(self, req_id: str, content: str) -> Optional[dict]: """Send a non-streaming text reply. Args: req_id: The req_id from the original message frame. content: The text content to reply. Returns: The ACK frame dict, or None on failure. """ body = { 'msgtype': 'markdown', 'markdown': { 'content': content, }, } return await self._send_reply(req_id, body) async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]: """Proactively send a message to a specified chat. Args: chat_id: The chat ID (userid for single chat, chatid for group chat). content: The message content. msgtype: Message type, 'markdown' by default. Returns: The ACK frame dict, or None on failure. """ req_id = _generate_req_id(CMD_SEND_MSG) body: dict[str, Any] = { 'chatid': chat_id, 'msgtype': msgtype, } if msgtype == 'markdown': body['markdown'] = {'content': content} elif msgtype == 'text': body['text'] = {'content': content} return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG) async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: """Push a streaming chunk for a given message ID. Compatible interface with WecomBotClient.push_stream_chunk. Args: msg_id: The original message ID. content: The cumulative content from the pipeline. is_final: Whether this is the final chunk. Returns: True if the stream session exists and chunk was sent. """ key = self._stream_ids.get(msg_id) if not key: return False req_id, stream_id = key.split('|', 1) try: # Skip sending if content hasn't changed (e.g. during tool call argument streaming) if not is_final and content == self._stream_last_content.get(msg_id): return True await self.reply_stream(req_id, stream_id, content, finish=is_final) self._stream_last_content[msg_id] = content if is_final: self._stream_ids.pop(msg_id, None) self._stream_last_content.pop(msg_id, None) return True except Exception: await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}') return False async def set_message(self, msg_id: str, content: str): """Fallback: send content as a final stream chunk or direct reply. Compatible interface with WecomBotClient.set_message. """ handled = await self.push_stream_chunk(msg_id, content, is_final=True) if not handled: await self.logger.warning(f'No active stream for msg_id={msg_id}, message dropped') # ── Connection lifecycle ──────────────────────────────────────── async def _connect_once(self): """Establish a single WebSocket connection, authenticate, and listen.""" await self.logger.info(f'Connecting to {self.ws_url}...') self._session = aiohttp.ClientSession() try: self._ws = await self._session.ws_connect(self.ws_url) self._missed_pong_count = 0 self._reconnect_attempts = 0 await self.logger.info('WebSocket connected, sending auth...') await self._send_auth() # Wait for auth response auth_ok = await self._wait_for_auth() if not auth_ok: await self.logger.error('Authentication failed') return await self.logger.info('Authenticated successfully') # Start heartbeat self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) try: await self._listen_loop() finally: if self._heartbeat_task and not self._heartbeat_task.done(): self._heartbeat_task.cancel() self._clear_pending_acks('Connection closed') finally: if self._ws and not self._ws.closed: await self._ws.close() self._ws = None if self._session and not self._session.closed: await self._session.close() self._session = None async def _send_auth(self): """Send the authentication frame.""" frame = { 'cmd': CMD_SUBSCRIBE, 'headers': {'req_id': _generate_req_id(CMD_SUBSCRIBE)}, 'body': { 'bot_id': self.bot_id, 'secret': self.secret, }, } await self._send_frame(frame) async def _wait_for_auth(self) -> bool: """Wait for and validate the authentication response.""" try: msg = await asyncio.wait_for(self._ws.receive(), timeout=10.0) if msg.type in (aiohttp.WSMsgType.TEXT,): frame = json.loads(msg.data) req_id = frame.get('headers', {}).get('req_id', '') if req_id.startswith(CMD_SUBSCRIBE) and frame.get('errcode') == 0: return True await self.logger.error(f'Auth response: errcode={frame.get("errcode")}, errmsg={frame.get("errmsg")}') return False elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING): await self.logger.error(f'WebSocket closed during auth: {msg.type}') return False await self.logger.error(f'Unexpected message type during auth: {msg.type}') return False except asyncio.TimeoutError: await self.logger.error('Auth response timeout') return False async def _heartbeat_loop(self): """Periodically send heartbeat pings.""" try: while self._running and self._ws and not self._ws.closed: await asyncio.sleep(self.heartbeat_interval) if not self._running or not self._ws or self._ws.closed: break if self._missed_pong_count >= self._max_missed_pong: await self.logger.warning( f'No heartbeat ack for {self._missed_pong_count} consecutive pings, connection considered dead' ) await self._ws.close() break self._missed_pong_count += 1 frame = { 'cmd': CMD_HEARTBEAT, 'headers': {'req_id': _generate_req_id(CMD_HEARTBEAT)}, } try: await self._send_frame(frame) except Exception: break except asyncio.CancelledError: pass async def _listen_loop(self): """Listen for incoming WebSocket frames and dispatch them.""" async for msg in self._ws: if not self._running: break if msg.type == aiohttp.WSMsgType.TEXT: try: frame = json.loads(msg.data) await self._handle_frame(frame) except json.JSONDecodeError: await self.logger.error(f'Failed to parse WebSocket message: {str(msg.data)[:200]}') except Exception: await self.logger.error(f'Error handling frame: {traceback.format_exc()}') elif msg.type == aiohttp.WSMsgType.BINARY: try: frame = json.loads(msg.data) await self._handle_frame(frame) except Exception: await self.logger.error(f'Error handling binary frame: {traceback.format_exc()}') elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING): await self.logger.warning(f'WebSocket connection closed: {msg.type}') break # ── Frame handling ────────────────────────────────────────────── async def _handle_frame(self, frame: dict): """Route an incoming frame to the appropriate handler.""" cmd = frame.get('cmd', '') # Message push if cmd == CMD_MSG_CALLBACK: asyncio.create_task(self._handle_message_callback(frame)) return # Event push if cmd == CMD_EVENT_CALLBACK: asyncio.create_task(self._handle_event_callback(frame)) return # No cmd → response/ACK frame, dispatch by req_id prefix req_id = frame.get('headers', {}).get('req_id', '') # Check pending ACKs first if req_id in self._pending_acks: future = self._pending_acks.pop(req_id) if not future.done(): future.set_result(frame) return # Heartbeat response if req_id.startswith(CMD_HEARTBEAT): if frame.get('errcode') == 0: self._missed_pong_count = 0 return # Unknown frame await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}') async def _handle_message_callback(self, frame: dict): """Handle an incoming message callback frame.""" try: body = frame.get('body', {}) req_id = frame.get('headers', {}).get('req_id', '') # Parse message using shared logic message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger) if not message_data: return # Generate stream_id for this message and store the mapping stream_id = _generate_req_id('stream') msg_id = message_data.get('msgid', '') if msg_id: self._stream_ids[msg_id] = f'{req_id}|{stream_id}' message_data['stream_id'] = stream_id message_data['req_id'] = req_id event = wecombotevent.WecomBotEvent(message_data) await self._dispatch_event(event) except Exception: await self.logger.error(f'Error in message callback: {traceback.format_exc()}') async def _handle_event_callback(self, frame: dict): """Handle an incoming event callback frame (enter_chat, template_card_event, etc.).""" try: body = frame.get('body', {}) req_id = frame.get('headers', {}).get('req_id', '') event_info = body.get('event', {}) event_type = event_info.get('eventtype', '') message_data = { 'msgtype': 'event', 'type': body.get('chattype', 'single'), 'event': event_info, 'eventtype': event_type, 'msgid': body.get('msgid', ''), 'aibotid': body.get('aibotid', ''), 'req_id': req_id, } from_info = body.get('from', {}) message_data['userid'] = from_info.get('userid', '') message_data['username'] = from_info.get('alias', '') or from_info.get('userid', '') if body.get('chatid'): message_data['chatid'] = body.get('chatid', '') event = wecombotevent.WecomBotEvent(message_data) # Dispatch to event-specific handlers if event_type in self._message_handlers: for handler in self._message_handlers[event_type]: await handler(event) # Also dispatch to generic 'event' handlers if 'event' in self._message_handlers: for handler in self._message_handlers['event']: await handler(event) except Exception: await self.logger.error(f'Error in event callback: {traceback.format_exc()}') async def _dispatch_event(self, event: wecombotevent.WecomBotEvent): """Dispatch a message event to registered handlers with deduplication.""" try: message_id = event.message_id if message_id in self._msg_id_map: self._msg_id_map[message_id] += 1 return self._msg_id_map[message_id] = 1 msg_type = event.type if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) except Exception: await self.logger.error(f'Error dispatching event: {traceback.format_exc()}') # ── Reply sending with serial queue ───────────────────────────── async def _send_reply( self, req_id: str, body: dict, cmd: str = CMD_RESPOND_MSG, ) -> Optional[dict]: """Send a reply frame and wait for ACK. Replies with the same req_id are serialized to maintain ordering. """ if not self._ws or self._ws.closed: return None frame = { 'cmd': cmd, 'headers': {'req_id': req_id}, 'body': body, } # Ensure serial delivery per req_id if req_id not in self._reply_queues: self._reply_queues[req_id] = asyncio.Queue() self._reply_workers[req_id] = asyncio.create_task(self._reply_queue_worker(req_id)) future: asyncio.Future = asyncio.get_event_loop().create_future() await self._reply_queues[req_id].put((frame, future)) return await future async def _reply_queue_worker(self, req_id: str): """Process reply queue items serially for a given req_id.""" queue = self._reply_queues[req_id] try: while self._running: try: frame, future = await asyncio.wait_for(queue.get(), timeout=60.0) except asyncio.TimeoutError: # Queue idle, clean up worker break try: ack = await self._send_and_wait_ack(frame) if not future.done(): future.set_result(ack) except Exception as e: if not future.done(): future.set_exception(e) except asyncio.CancelledError: pass finally: self._reply_queues.pop(req_id, None) self._reply_workers.pop(req_id, None) async def _send_and_wait_ack(self, frame: dict) -> Optional[dict]: """Send a frame and wait for the corresponding ACK.""" req_id = frame['headers']['req_id'] ack_future: asyncio.Future = asyncio.get_event_loop().create_future() self._pending_acks[req_id] = ack_future try: await self._send_frame(frame) result = await asyncio.wait_for(ack_future, timeout=self._reply_ack_timeout) if result.get('errcode', 0) != 0: await self.logger.warning( f'Reply ACK error: errcode={result.get("errcode")}, errmsg={result.get("errmsg")}' ) return result except asyncio.TimeoutError: self._pending_acks.pop(req_id, None) await self.logger.warning(f'Reply ACK timeout ({self._reply_ack_timeout}s) for req_id={req_id}') return None async def _send_frame(self, frame: dict): """Send a JSON frame over the WebSocket connection.""" if self._ws and not self._ws.closed: await self._ws.send_str(json.dumps(frame, ensure_ascii=False)) def _clear_pending_acks(self, reason: str): """Reject all pending ACK futures on disconnection.""" for req_id, future in self._pending_acks.items(): if not future.done(): future.set_exception(ConnectionError(reason)) self._pending_acks.clear() ================================================ FILE: src/langbot/libs/wecom_api/WXBizMsgCrypt3.py ================================================ #!/usr/bin/env python # -*- encoding:utf-8 -*- """对企业微信发送给企业后台的消息加解密示例代码. @copyright: Copyright (c) 1998-2014 Tencent Inc. """ # ------------------------------------------------------------------------ import logging import base64 import random import hashlib import time import struct from Crypto.Cipher import AES import xml.etree.cElementTree as ET import socket from . import ierror """ Crypto.Cipher包已不再维护,开发者可以通过以下命令下载安装最新版的加解密工具包 pip install pycryptodome """ class FormatException(Exception): pass def throw_exception(message, exception_class=FormatException): """my define raise exception function""" raise exception_class(message) class SHA1: """计算企业微信的消息签名接口""" def getSHA1(self, token, timestamp, nonce, encrypt): """用SHA1算法生成安全签名 @param token: 票据 @param timestamp: 时间戳 @param encrypt: 密文 @param nonce: 随机字符串 @return: 安全签名 """ try: sortlist = [token, timestamp, nonce, encrypt] sortlist.sort() sha = hashlib.sha1() sha.update(''.join(sortlist).encode()) return ierror.WXBizMsgCrypt_OK, sha.hexdigest() except Exception as e: logger = logging.getLogger() logger.error(e) return ierror.WXBizMsgCrypt_ComputeSignature_Error, None class XMLParse: """提供提取消息格式中的密文及生成回复消息格式的接口""" # xml消息模板 AES_TEXT_RESPONSE_TEMPLATE = """ %(timestamp)s """ def extract(self, xmltext): """提取出xml数据包中的加密消息 @param xmltext: 待提取的xml字符串 @return: 提取出的加密消息字符串 """ try: xml_tree = ET.fromstring(xmltext) encrypt = xml_tree.find('Encrypt') return ierror.WXBizMsgCrypt_OK, encrypt.text except Exception as e: logger = logging.getLogger() logger.error(e) return ierror.WXBizMsgCrypt_ParseXml_Error, None def generate(self, encrypt, signature, timestamp, nonce): """生成xml消息 @param encrypt: 加密后的消息密文 @param signature: 安全签名 @param timestamp: 时间戳 @param nonce: 随机字符串 @return: 生成的xml字符串 """ resp_dict = { 'msg_encrypt': encrypt, 'msg_signaturet': signature, 'timestamp': timestamp, 'nonce': nonce, } resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict return resp_xml class PKCS7Encoder: """提供基于PKCS7算法的加解密接口""" block_size = 32 def encode(self, text): """对需要加密的明文进行填充补位 @param text: 需要进行填充补位操作的明文 @return: 补齐明文字符串 """ text_length = len(text) # 计算需要填充的位数 amount_to_pad = self.block_size - (text_length % self.block_size) if amount_to_pad == 0: amount_to_pad = self.block_size # 获得补位所用的字符 pad = chr(amount_to_pad) return text + (pad * amount_to_pad).encode() def decode(self, decrypted): """删除解密后明文的补位字符 @param decrypted: 解密后的明文 @return: 删除补位字符后的明文 """ pad = ord(decrypted[-1]) if pad < 1 or pad > 32: pad = 0 return decrypted[:-pad] class Prpcrypt(object): """提供接收和推送给企业微信消息的加解密接口""" def __init__(self, key): # self.key = base64.b64decode(key+"=") self.key = key # 设置加解密模式为AES的CBC模式 self.mode = AES.MODE_CBC def encrypt(self, text, receiveid): """对明文进行加密 @param text: 需要加密的明文 @return: 加密得到的字符串 """ # 16位随机字符串添加到明文开头 text = text.encode() text = self.get_random_str() + struct.pack('I', socket.htonl(len(text))) + text + receiveid.encode() # 使用自定义的填充方式对明文进行补位填充 pkcs7 = PKCS7Encoder() text = pkcs7.encode(text) # 加密 cryptor = AES.new(self.key, self.mode, self.key[:16]) try: ciphertext = cryptor.encrypt(text) # 使用BASE64对加密后的字符串进行编码 return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext) except Exception as e: logger = logging.getLogger() logger.error(e) return ierror.WXBizMsgCrypt_EncryptAES_Error, None def decrypt(self, text, receiveid): """对解密后的明文进行补位删除 @param text: 密文 @return: 删除填充补位后的明文 """ try: cryptor = AES.new(self.key, self.mode, self.key[:16]) # 使用BASE64对密文进行解码,然后AES-CBC解密 plain_text = cryptor.decrypt(base64.b64decode(text)) except Exception as e: logger = logging.getLogger() logger.error(e) return ierror.WXBizMsgCrypt_DecryptAES_Error, None try: pad = plain_text[-1] # 去掉补位字符串 # pkcs7 = PKCS7Encoder() # plain_text = pkcs7.encode(plain_text) # 去除16位随机字符串 content = plain_text[16:-pad] xml_len = socket.ntohl(struct.unpack('I', content[:4])[0]) xml_content = content[4 : xml_len + 4] from_receiveid = content[xml_len + 4 :] except Exception as e: logger = logging.getLogger() logger.error(e) return ierror.WXBizMsgCrypt_IllegalBuffer, None if from_receiveid.decode('utf8') != receiveid: return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None return 0, xml_content def get_random_str(self): """随机生成16位字符串 @return: 16位字符串 """ return str(random.randint(1000000000000000, 9999999999999999)).encode() class WXBizMsgCrypt(object): # 构造函数 def __init__(self, sToken, sEncodingAESKey, sReceiveId): try: self.key = base64.b64decode(sEncodingAESKey + '=') assert len(self.key) == 32 except Exception: throw_exception('[error]: EncodingAESKey unvalid !', FormatException) # return ierror.WXBizMsgCrypt_IllegalAesKey,None self.m_sToken = sToken self.m_sReceiveId = sReceiveId # 验证URL # @param sMsgSignature: 签名串,对应URL参数的msg_signature # @param sTimeStamp: 时间戳,对应URL参数的timestamp # @param sNonce: 随机串,对应URL参数的nonce # @param sEchoStr: 随机串,对应URL参数的echostr # @param sReplyEchoStr: 解密之后的echostr,当return返回0时有效 # @return:成功0,失败返回对应的错误码 def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr): sha1 = SHA1() ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr) if ret != 0: return ret, None if not signature == sMsgSignature: return ierror.WXBizMsgCrypt_ValidateSignature_Error, None pc = Prpcrypt(self.key) ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId) return ret, sReplyEchoStr def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None): # 将企业回复用户的消息加密打包 # @param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串 # @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间 # @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce # sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串, # return:成功0,sEncryptMsg,失败返回对应的错误码None pc = Prpcrypt(self.key) ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId) encrypt = encrypt.decode('utf8') if ret != 0: return ret, None if timestamp is None: timestamp = str(int(time.time())) # 生成安全签名 sha1 = SHA1() ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt) if ret != 0: return ret, None xmlParse = XMLParse() return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce) def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce): # 检验消息的真实性,并且获取解密后的明文 # @param sMsgSignature: 签名串,对应URL参数的msg_signature # @param sTimeStamp: 时间戳,对应URL参数的timestamp # @param sNonce: 随机串,对应URL参数的nonce # @param sPostData: 密文,对应POST请求的数据 # xml_content: 解密后的原文,当return返回0时有效 # @return: 成功0,失败返回对应的错误码 # 验证安全签名 xmlParse = XMLParse() ret, encrypt = xmlParse.extract(sPostData) if ret != 0: return ret, None sha1 = SHA1() ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt) if ret != 0: return ret, None if not signature == sMsgSignature: return ierror.WXBizMsgCrypt_ValidateSignature_Error, None pc = Prpcrypt(self.key) ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId) return ret, xml_content ================================================ FILE: src/langbot/libs/wecom_api/__init__.py ================================================ ================================================ FILE: src/langbot/libs/wecom_api/api.py ================================================ from quart import request from .WXBizMsgCrypt3 import WXBizMsgCrypt import base64 import binascii import httpx import traceback from quart import Quart import xml.etree.ElementTree as ET from typing import Callable, Dict, Any from .wecomevent import WecomEvent import langbot_plugin.api.entities.builtin.platform.message as platform_message import aiofiles class WecomClient: def __init__( self, corpid: str, secret: str, token: str, EncodingAESKey: str, contacts_secret: str, logger: None, unified_mode: bool = False, api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin', ): self.corpid = corpid self.secret = secret self.access_token_for_contacts = '' self.token = token self.aes = EncodingAESKey self.base_url = api_base_url self.access_token = '' self.secret_for_contacts = contacts_secret self.logger = logger self.unified_mode = unified_mode self.app = Quart(__name__) # 只有在非统一模式下才注册独立路由 if not self.unified_mode: self.app.add_url_rule( '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'], ) self._message_handlers = { 'example': [], } # access——token操作 async def check_access_token(self): return bool(self.access_token and self.access_token.strip()) async def check_access_token_for_contacts(self): return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip()) async def get_access_token(self, secret): url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}' async with httpx.AsyncClient() as client: response = await client.get(url) data = response.json() if 'access_token' in data: return data['access_token'] else: await self.logger.error(f'获取accesstoken失败:{response.json()}') raise Exception(f'未获取access token: {data}') async def get_users(self): if not self.check_access_token_for_contacts(): self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts) url = self.base_url + '/user/list_id?access_token=' + self.access_token_for_contacts async with httpx.AsyncClient() as client: params = { 'cursor': '', 'limit': 10000, } response = await client.post(url, json=params) data = response.json() if data['errcode'] == 0: dept_users = data['dept_user'] userid = [] for user in dept_users: userid.append(user['userid']) return userid else: raise Exception('未获取用户') async def send_to_all(self, content: str, agent_id: int): if not self.check_access_token_for_contacts(): self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts) url = self.base_url + '/message/send?access_token=' + self.access_token_for_contacts user_ids = await self.get_users() user_ids_string = '|'.join(user_ids) async with httpx.AsyncClient() as client: params = { 'touser': user_ids_string, 'msgtype': 'text', 'agentid': agent_id, 'text': { 'content': content, }, 'safe': 0, 'enable_id_trans': 0, 'enable_duplicate_check': 0, 'duplicate_check_interval': 1800, } response = await client.post(url, json=params) data = response.json() if data['errcode'] != 0: raise Exception('Failed to send message: ' + str(data)) async def send_image(self, user_id: str, agent_id: int, media_id: str): if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = self.base_url + '/message/send?access_token=' + self.access_token async with httpx.AsyncClient() as client: params = { 'touser': user_id, 'msgtype': 'image', 'agentid': agent_id, 'image': { 'media_id': media_id, }, 'safe': 0, 'enable_id_trans': 0, 'enable_duplicate_check': 0, 'duplicate_check_interval': 1800, } response = await client.post(url, json=params) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) return await self.send_image(user_id, agent_id, media_id) if data['errcode'] != 0: await self.logger.error(f'发送图片失败:{data}') raise Exception('Failed to send image: ' + str(data)) async def send_voice(self, user_id: str, agent_id: int, media_id: str): if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = self.base_url + '/message/send?access_token=' + self.access_token async with httpx.AsyncClient() as client: params = { 'touser': user_id, 'msgtype': 'voice', 'agentid': agent_id, 'voice': { 'media_id': media_id, }, 'safe': 0, 'enable_id_trans': 0, 'enable_duplicate_check': 0, 'duplicate_check_interval': 1800, } response = await client.post(url, json=params) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) return await self.send_voice(user_id, agent_id, media_id) if data['errcode'] != 0: await self.logger.error(f'发送语音失败:{data}') raise Exception('Failed to send voice: ' + str(data)) async def send_file(self, user_id: str, agent_id: int, media_id: str): if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = self.base_url + '/message/send?access_token=' + self.access_token async with httpx.AsyncClient() as client: params = { 'touser': user_id, 'msgtype': 'file', 'agentid': agent_id, 'file': { 'media_id': media_id, }, 'safe': 0, 'enable_id_trans': 0, 'enable_duplicate_check': 0, 'duplicate_check_interval': 1800, } response = await client.post(url, json=params) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) return await self.send_file(user_id, agent_id, media_id) if data['errcode'] != 0: await self.logger.error(f'发送文件失败:{data}') raise Exception('Failed to send file: ' + str(data)) async def send_private_msg(self, user_id: str, agent_id: int, content: str): if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = self.base_url + '/message/send?access_token=' + self.access_token async with httpx.AsyncClient(timeout=None) as client: params = { 'touser': user_id, 'msgtype': 'text', 'agentid': agent_id, 'text': { 'content': content, }, 'safe': 0, 'enable_id_trans': 0, 'enable_duplicate_check': 0, 'duplicate_check_interval': 1800, } response = await client.post(url, json=params) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) return await self.send_private_msg(user_id, agent_id, content) if data['errcode'] != 0: await self.logger.error(f'发送消息失败:{data}') raise Exception('Failed to send message: ' + str(data)) async def handle_callback_request(self): """处理回调请求(独立端口模式,使用全局 request)。""" return await self._handle_callback_internal(request) async def handle_unified_webhook(self, req): """处理回调请求(统一 webhook 模式,显式传递 request)。 Args: req: Quart Request 对象 Returns: 响应数据 """ return await self._handle_callback_internal(req) async def _handle_callback_internal(self, req): """ 处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 Args: req: Quart Request 对象 """ try: msg_signature = req.args.get('msg_signature') timestamp = req.args.get('timestamp') nonce = req.args.get('nonce') wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) if req.method == 'GET': echostr = req.args.get('echostr') ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) if ret != 0: await self.logger.error('验证失败') raise Exception(f'验证失败,错误码: {ret}') return reply_echo_str elif req.method == 'POST': encrypt_msg = await req.data ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) if ret != 0: await self.logger.error('消息解密失败') raise Exception(f'消息解密失败,错误码: {ret}') # 解析消息并处理 message_data = await self.get_message(xml_msg) if message_data: event = WecomEvent.from_payload(message_data) # 转换为 WecomEvent 对象 if event: await self._handle_message(event) return 'success' except Exception as e: await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') return f'Error processing request: {str(e)}', 400 async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 """ await self.app.run_task(host=host, port=port, *args, **kwargs) def on_message(self, msg_type: str): """ 注册消息类型处理器。 """ def decorator(func: Callable[[WecomEvent], None]): if msg_type not in self._message_handlers: self._message_handlers[msg_type] = [] self._message_handlers[msg_type].append(func) return func return decorator async def _handle_message(self, event: WecomEvent): """ 处理消息事件。 """ msg_type = event.type if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) async def get_message(self, xml_msg: str) -> Dict[str, Any]: """ 解析微信返回的 XML 消息并转换为字典。 """ root = ET.fromstring(xml_msg) message_data = { 'ToUserName': root.find('ToUserName').text, 'FromUserName': root.find('FromUserName').text, 'CreateTime': int(root.find('CreateTime').text), 'MsgType': root.find('MsgType').text, 'Content': root.find('Content').text if root.find('Content') is not None else None, 'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None, 'AgentID': int(root.find('AgentID').text) if root.find('AgentID') is not None else None, } if message_data['MsgType'] == 'image': message_data['MediaId'] = root.find('MediaId').text if root.find('MediaId') is not None else None message_data['PicUrl'] = root.find('PicUrl').text if root.find('PicUrl') is not None else None return message_data @staticmethod async def get_image_type(image_bytes: bytes) -> str: """ 通过图片的magic numbers判断图片类型 """ magic_numbers = { b'\xff\xd8\xff': 'jpg', b'\x89\x50\x4e\x47': 'png', b'\x47\x49\x46': 'gif', b'\x42\x4d': 'bmp', b'\x00\x00\x01\x00': 'ico', } for magic, ext in magic_numbers.items(): if image_bytes.startswith(magic): return ext return 'jpg' # 默认返回jpg async def upload_image_to_work(self, image: platform_message.Image): """ 获取 media_id """ if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' file_bytes = None file_name = 'uploaded_file.txt' # 获取文件的二进制数据 if image.path: async with aiofiles.open(image.path, 'rb') as f: file_bytes = await f.read() file_name = image.path.split('/')[-1] elif image.url: file_bytes = await self.download_media_to_bytes(image.url) file_name = image.url.split('/')[-1] elif image.base64: try: base64_data = image.base64 if ',' in base64_data: base64_data = base64_data.split(',', 1)[1] padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0 padded_base64 = base64_data + '=' * padding file_bytes = base64.b64decode(padded_base64) except binascii.Error as e: raise ValueError(f'Invalid base64 string: {str(e)}') else: await self.logger.error('Image对象出错') raise ValueError('image对象出错') # 设置 multipart/form-data 格式的文件 boundary = '-------------------------acebdf13572468' headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'} body = ( ( f'--{boundary}\r\n' f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n' f'Content-Type: application/octet-stream\r\n\r\n' ).encode('utf-8') + file_bytes + f'\r\n--{boundary}--\r\n'.encode('utf-8') ) # 上传文件 async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, content=body) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) media_id = await self.upload_image_to_work(image) if data.get('errcode', 0) != 0: await self.logger.error(f'上传图片失败:{data}') raise Exception('failed to upload file') media_id = data.get('media_id') return media_id async def upload_voice_to_work(self, voice: platform_message.Voice): """ 上传语音文件到企业微信 """ if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' file_bytes = None file_name = 'voice.mp3' if voice.path: async with aiofiles.open(voice.path, 'rb') as f: file_bytes = await f.read() file_name = voice.path.split('/')[-1] elif voice.url: file_bytes = await self.download_media_to_bytes(voice.url) file_name = voice.url.split('/')[-1] elif voice.base64: try: base64_data = voice.base64 if ',' in base64_data: base64_data = base64_data.split(',', 1)[1] padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0 padded_base64 = base64_data + '=' * padding file_bytes = base64.b64decode(padded_base64) except binascii.Error as e: raise ValueError(f'Invalid base64 string: {str(e)}') else: await self.logger.error('Voice对象出错') raise ValueError('voice对象出错') boundary = '-------------------------acebdf13572468' headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'} body = ( ( f'--{boundary}\r\n' f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n' f'Content-Type: application/octet-stream\r\n\r\n' ).encode('utf-8') + file_bytes + f'\r\n--{boundary}--\r\n'.encode('utf-8') ) # print(body) async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, content=body) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) media_id = await self.upload_voice_to_work(voice) if data.get('errcode', 0) != 0: await self.logger.error(f'上传语音文件失败:{data}') raise Exception('failed to upload file') media_id = data.get('media_id') return media_id async def upload_file_to_work(self, file: platform_message.File): """ 上传文件到企业微信 """ if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' file_bytes = None file_name = 'file.txt' if file.path: async with aiofiles.open(file.path, 'rb') as f: file_bytes = await f.read() file_name = file.path.split('/')[-1] elif file.url: file_bytes = await self.download_media_to_bytes(file.url) file_name = file.url.split('/')[-1] elif file.base64: try: base64_data = file.base64 if ',' in base64_data: base64_data = base64_data.split(',', 1)[1] padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0 padded_base64 = base64_data + '=' * padding file_bytes = base64.b64decode(padded_base64) except binascii.Error as e: raise ValueError(f'Invalid base64 string: {str(e)}') else: await self.logger.error('File对象出错') raise ValueError('file对象出错') boundary = '-------------------------acebdf13572468' headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'} body = ( ( f'--{boundary}\r\n' f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n' f'Content-Type: application/octet-stream\r\n\r\n' ).encode('utf-8') + file_bytes + f'\r\n--{boundary}--\r\n'.encode('utf-8') ) async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, content=body) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) media_id = await self.upload_file_to_work(file) if data.get('errcode', 0) != 0: await self.logger.error(f'上传文件失败:{data}') raise Exception('failed to upload file') media_id = data.get('media_id') return media_id async def download_media_to_bytes(self, url: str) -> bytes: async with httpx.AsyncClient() as client: response = await client.get(url) response.raise_for_status() return response.content # 进行media_id的获取 async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File): if isinstance(media, platform_message.Image): media_id = await self.upload_image_to_work(image=media) elif isinstance(media, platform_message.Voice): media_id = await self.upload_voice_to_work(voice=media) elif isinstance(media, platform_message.File): media_id = await self.upload_file_to_work(file=media) else: raise ValueError('Unsupported media type') return media_id ================================================ FILE: src/langbot/libs/wecom_api/ierror.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- ######################################################################### # Author: jonyqin # Created Time: Thu 11 Sep 2014 01:53:58 PM CST # File Name: ierror.py # Description:定义错误码含义 ######################################################################### WXBizMsgCrypt_OK = 0 WXBizMsgCrypt_ValidateSignature_Error = -40001 WXBizMsgCrypt_ParseXml_Error = -40002 WXBizMsgCrypt_ComputeSignature_Error = -40003 WXBizMsgCrypt_IllegalAesKey = -40004 WXBizMsgCrypt_ValidateCorpid_Error = -40005 WXBizMsgCrypt_EncryptAES_Error = -40006 WXBizMsgCrypt_DecryptAES_Error = -40007 WXBizMsgCrypt_IllegalBuffer = -40008 WXBizMsgCrypt_EncodeBase64_Error = -40009 WXBizMsgCrypt_DecodeBase64_Error = -40010 WXBizMsgCrypt_GenReturnXml_Error = -40011 ================================================ FILE: src/langbot/libs/wecom_api/wecomevent.py ================================================ from typing import Dict, Any, Optional class WecomEvent(dict): """ 封装从企业微信收到的事件数据对象(字典),提供属性以获取其中的字段。 除 `type` 和 `detail_type` 属性对于任何事件都有效外,其它属性是否存在(若不存在则返回 `None`)依事件类型不同而不同。 """ @staticmethod def from_payload(payload: Dict[str, Any]) -> Optional['WecomEvent']: """ 从企业微信事件数据构造 `WecomEvent` 对象。 Args: payload (Dict[str, Any]): 解密后的企业微信事件数据。 Returns: Optional[WecomEvent]: 如果事件数据合法,则返回 WecomEvent 对象;否则返回 None。 """ try: event = WecomEvent(payload) _ = event.type, event.detail_type # 确保必须字段存在 return event except KeyError: return None @property def type(self) -> str: """ 事件类型,例如 "message"、"event"、"text" 等。 Returns: str: 事件类型。 """ return self.get('MsgType', '') @property def picurl(self) -> str: """ 图片链接 """ return self.get('PicUrl') @property def detail_type(self) -> str: """ 事件详细类型,依 `type` 的不同而不同。例如: - 消息事件: "text", "image", "voice", 等 - 事件通知: "subscribe", "unsubscribe", "click", 等 Returns: str: 事件详细类型。 """ if self.type == 'event': return self.get('Event', '') return self.type @property def name(self) -> str: """ 事件名,对于消息事件是 `type.detail_type`,对于其他事件是 `event_type`。 Returns: str: 事件名。 """ return f'{self.type}.{self.detail_type}' @property def user_id(self) -> Optional[str]: """ 用户 ID,例如消息的发送者或事件的触发者。 Returns: Optional[str]: 用户 ID。 """ return self.get('FromUserName') @property def agent_id(self) -> Optional[int]: """ 机器人 ID,仅在消息类型事件中存在。 Returns: Optional[int]: 机器人 ID。 """ return self.get('AgentID') @property def receiver_id(self) -> Optional[str]: """ 接收者 ID,例如机器人自身的企业微信 ID。 Returns: Optional[str]: 接收者 ID。 """ return self.get('ToUserName') @property def message_id(self) -> Optional[str]: """ 消息 ID,仅在消息类型事件中存在。 Returns: Optional[str]: 消息 ID。 """ return self.get('MsgId') @property def message(self) -> Optional[str]: """ 消息内容,仅在消息类型事件中存在。 Returns: Optional[str]: 消息内容。 """ return self.get('Content') @property def media_id(self) -> Optional[str]: """ 媒体文件 ID,仅在图片、语音等消息类型中存在。 Returns: Optional[str]: 媒体文件 ID。 """ return self.get('MediaId') @property def timestamp(self) -> Optional[int]: """ 事件发生的时间戳。 Returns: Optional[int]: 时间戳。 """ return self.get('CreateTime') @property def event_key(self) -> Optional[str]: """ 事件的 Key 值,例如点击菜单时的 `EventKey`。 Returns: Optional[str]: 事件 Key。 """ return self.get('EventKey') def __getattr__(self, key: str) -> Optional[Any]: """ 允许通过属性访问数据中的任意字段。 Args: key (str): 字段名。 Returns: Optional[Any]: 字段值。 """ return self.get(key) def __setattr__(self, key: str, value: Any) -> None: """ 允许通过属性设置数据中的任意字段。 Args: key (str): 字段名。 value (Any): 字段值。 """ self[key] = value def __repr__(self) -> str: """ 生成事件对象的字符串表示。 Returns: str: 字符串表示。 """ return f'' ================================================ FILE: src/langbot/libs/wecom_customer_service_api/__init__.py ================================================ ================================================ FILE: src/langbot/libs/wecom_customer_service_api/api.py ================================================ from quart import request from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt import base64 import binascii import httpx import traceback from quart import Quart import xml.etree.ElementTree as ET from typing import Callable from .wecomcsevent import WecomCSEvent import langbot_plugin.api.entities.builtin.platform.message as platform_message import aiofiles import time class WecomCSClient: def __init__( self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None, unified_mode: bool = False, api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin', ): self.corpid = corpid self.secret = secret self.access_token_for_contacts = '' self.token = token self.aes = EncodingAESKey self.base_url = api_base_url self.access_token = '' self.logger = logger self.unified_mode = unified_mode self.app = Quart(__name__) # Customer info cache: {external_userid: (info_dict, timestamp)} self._customer_cache: dict[str, tuple[dict, float]] = {} self._cache_ttl = 60 # Cache TTL in seconds (1 minute) # 只有在非统一模式下才注册独立路由 if not self.unified_mode: self.app.add_url_rule( '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'] ) self._message_handlers = { 'example': [], } async def get_pic_url(self, media_id: str): if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = f'{self.base_url}/media/get?access_token={self.access_token}&media_id={media_id}' async with httpx.AsyncClient() as client: response = await client.get(url) if response.headers.get('Content-Type', '').startswith('application/json'): data = response.json() if data.get('errcode') in [40014, 42001]: self.access_token = await self.get_access_token(self.secret) return await self.get_pic_url(media_id) else: raise Exception('Failed to get image: ' + str(data)) # 否则是图片,转成 base64 image_bytes = response.content content_type = response.headers.get('Content-Type', '') base64_str = base64.b64encode(image_bytes).decode('utf-8') base64_str = f'data:{content_type};base64,{base64_str}' return base64_str # access——token操作 async def check_access_token(self): return bool(self.access_token and self.access_token.strip()) async def check_access_token_for_contacts(self): return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip()) async def get_access_token(self, secret): url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}' async with httpx.AsyncClient() as client: response = await client.get(url) data = response.json() if 'access_token' in data: return data['access_token'] else: raise Exception(f'未获取access token: {data}') async def get_detailed_message_list(self, xml_msg: str): # 在本方法中解析消息,并且获得消息的具体内容 if isinstance(xml_msg, bytes): xml_msg = xml_msg.decode('utf-8') root = ET.fromstring(xml_msg) token = root.find('Token').text open_kfid = root.find('OpenKfId').text # if open_kfid in self.openkfid_list: # return None # else: # self.openkfid_list.append(open_kfid) if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = self.base_url + '/kf/sync_msg?access_token=' + self.access_token async with httpx.AsyncClient() as client: params = { 'token': token, 'voice_format': 0, 'open_kfid': open_kfid, } response = await client.post(url, json=params) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) return await self.get_detailed_message_list(xml_msg) if data['errcode'] != 0: raise Exception('Failed to get message') last_msg_data = data['msg_list'][-1] open_kfid = last_msg_data.get('open_kfid') # 进行获取图片操作 if last_msg_data.get('msgtype') == 'image': media_id = last_msg_data.get('image').get('media_id') picurl = await self.get_pic_url(media_id) last_msg_data['picurl'] = picurl # await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer) return last_msg_data async def change_service_status(self, userid: str, openkfid: str, servicer: str): if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = self.base_url + '/kf/service_state/get?access_token=' + self.access_token async with httpx.AsyncClient() as client: params = { 'open_kfid': openkfid, 'external_userid': userid, 'service_state': 1, 'servicer_userid': servicer, } response = await client.post(url, json=params) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) return await self.change_service_status(userid, openkfid) if data['errcode'] != 0: raise Exception('Failed to change service status: ' + str(data)) async def send_image(self, user_id: str, agent_id: int, media_id: str): if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = self.base_url + '/media/upload?access_token=' + self.access_token async with httpx.AsyncClient() as client: params = { 'touser': user_id, 'toparty': '', 'totag': '', 'agentid': agent_id, 'msgtype': 'image', 'image': { 'media_id': media_id, }, 'safe': 0, 'enable_id_trans': 0, 'enable_duplicate_check': 0, 'duplicate_check_interval': 1800, } try: response = await client.post(url, json=params) data = response.json() except Exception as e: raise Exception('Failed to send image: ' + str(e)) # 企业微信错误码40014和42001,代表accesstoken问题 if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) return await self.send_image(user_id, agent_id, media_id) if data['errcode'] != 0: raise Exception('Failed to send image: ' + str(data)) async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, content: str): if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}' payload = { 'touser': external_userid, 'open_kfid': open_kfid, 'msgid': msgid, 'msgtype': 'text', 'text': { 'content': content, }, } async with httpx.AsyncClient() as client: response = await client.post(url, json=payload) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) return await self.send_text_msg(open_kfid, external_userid, msgid, content) if data['errcode'] != 0: await self.logger.error(f'发送消息失败:{data}') raise Exception('Failed to send message') return data async def handle_callback_request(self): """处理回调请求(独立端口模式,使用全局 request)。""" return await self._handle_callback_internal(request) async def handle_unified_webhook(self, req): """处理回调请求(统一 webhook 模式,显式传递 request)。 Args: req: Quart Request 对象 Returns: 响应数据 """ return await self._handle_callback_internal(req) async def _handle_callback_internal(self, req): """ 处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 Args: req: Quart Request 对象 """ try: msg_signature = req.args.get('msg_signature') timestamp = req.args.get('timestamp') nonce = req.args.get('nonce') try: wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) except Exception as e: raise Exception(f'初始化失败,错误码: {e}') if req.method == 'GET': echostr = req.args.get('echostr') ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) if ret != 0: raise Exception(f'验证失败,错误码: {ret}') return reply_echo_str elif req.method == 'POST': encrypt_msg = await req.data ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) if ret != 0: raise Exception(f'消息解密失败,错误码: {ret}') # 解析消息并处理 message_data = await self.get_detailed_message_list(xml_msg) if message_data is not None: event = WecomCSEvent.from_payload(message_data) if event: await self._handle_message(event) return 'success' except Exception as e: if self.logger: await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') else: traceback.print_exc() return f'Error processing request: {str(e)}', 400 async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 """ await self.app.run_task(host=host, port=port, *args, **kwargs) def on_message(self, msg_type: str): """ 注册消息类型处理器。 """ def decorator(func: Callable[[WecomCSEvent], None]): if msg_type not in self._message_handlers: self._message_handlers[msg_type] = [] self._message_handlers[msg_type].append(func) return func return decorator async def _handle_message(self, event: WecomCSEvent): """ 处理消息事件。 """ msg_type = event.type if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) @staticmethod async def get_image_type(image_bytes: bytes) -> str: """ 通过图片的magic numbers判断图片类型 """ magic_numbers = { b'\xff\xd8\xff': 'jpg', b'\x89\x50\x4e\x47': 'png', b'\x47\x49\x46': 'gif', b'\x42\x4d': 'bmp', b'\x00\x00\x01\x00': 'ico', } for magic, ext in magic_numbers.items(): if image_bytes.startswith(magic): return ext return 'jpg' # 默认返回jpg async def upload_to_work(self, image: platform_message.Image): """ 获取 media_id """ if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' file_bytes = None file_name = 'uploaded_file.txt' # 获取文件的二进制数据 if image.path: async with aiofiles.open(image.path, 'rb') as f: file_bytes = await f.read() file_name = image.path.split('/')[-1] elif image.url: file_bytes = await self.download_image_to_bytes(image.url) file_name = image.url.split('/')[-1] elif image.base64: try: base64_data = image.base64 if ',' in base64_data: base64_data = base64_data.split(',', 1)[1] padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0 padded_base64 = base64_data + '=' * padding file_bytes = base64.b64decode(padded_base64) except binascii.Error as e: raise ValueError(f'Invalid base64 string: {str(e)}') else: raise ValueError('image对象出错') # 设置 multipart/form-data 格式的文件 boundary = '-------------------------acebdf13572468' headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'} body = ( ( f'--{boundary}\r\n' f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n' f'Content-Type: application/octet-stream\r\n\r\n' ).encode('utf-8') + file_bytes + f'\r\n--{boundary}--\r\n'.encode('utf-8') ) # 上传文件 async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, content=body) data = response.json() if data['errcode'] == 40014 or data['errcode'] == 42001: self.access_token = await self.get_access_token(self.secret) media_id = await self.upload_to_work(image) if data.get('errcode', 0) != 0: raise Exception('failed to upload file') media_id = data.get('media_id') return media_id async def download_image_to_bytes(self, url: str) -> bytes: async with httpx.AsyncClient() as client: response = await client.get(url) response.raise_for_status() return response.content # 进行media_id的获取 async def get_media_id(self, image: platform_message.Image): media_id = await self.upload_to_work(image=image) return media_id async def get_customer_info(self, external_userid: str) -> dict | None: """ Get customer information by external_userid with caching. Uses a 1-minute cache to avoid repeated API calls for the same user. Args: external_userid: The external user ID of the customer. Returns: Customer info dict with 'nickname', 'avatar', etc., or None if not found. """ # Check cache first current_time = time.time() if external_userid in self._customer_cache: cached_info, cached_time = self._customer_cache[external_userid] if current_time - cached_time < self._cache_ttl: return cached_info # Cache miss or expired, fetch from API if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}' payload = { 'external_userid_list': [external_userid], } async with httpx.AsyncClient() as client: response = await client.post(url, json=payload) data = response.json() if data.get('errcode') in [40014, 42001]: self.access_token = await self.get_access_token(self.secret) return await self.get_customer_info(external_userid) if data.get('errcode', 0) != 0: if self.logger: await self.logger.warning(f'Failed to get customer info: {data}') return None customer_list = data.get('customer_list', []) if customer_list: customer_info = customer_list[0] # Store in cache self._customer_cache[external_userid] = (customer_info, current_time) return customer_info return None ================================================ FILE: src/langbot/libs/wecom_customer_service_api/wecomcsevent.py ================================================ from typing import Dict, Any, Optional class WecomCSEvent(dict): """ 封装从企业微信收到的事件数据对象(字典),提供属性以获取其中的字段。 除 `type` 和 `detail_type` 属性对于任何事件都有效外,其它属性是否存在(若不存在则返回 `None`)依事件类型不同而不同。 """ @staticmethod def from_payload(payload: Dict[str, Any]) -> Optional['WecomCSEvent']: """ 从企业微信(客服会话)事件数据构造 `WecomEvent` 对象。 Args: payload (Dict[str, Any]): 解密后的企业微信事件数据。 Returns: Optional[WecomEvent]: 如果事件数据合法,则返回 WecomEvent 对象;否则返回 None。 """ try: event = WecomCSEvent(payload) _ = (event.type,) return event except KeyError: return None @property def type(self) -> str: """ 事件类型,例如 "message"、"event"、"text" 等。 Returns: str: 事件类型。 """ return self.get('msgtype', '') @property def user_id(self) -> Optional[str]: """ 用户 ID,例如消息的发送者或事件的触发者。 Returns: Optional[str]: 用户 ID。 """ return self.get('external_userid') @property def receiver_id(self) -> Optional[str]: """ 接收者 ID,例如机器人自身的企业微信 ID。 Returns: Optional[str]: 接收者 ID。 """ return self.get('open_kfid', '') @property def picurl(self) -> Optional[str]: """ 图片 URL,仅在图片消息中存在。 base64格式 Returns: Optional[str]: 图片 URL。 """ return self.get('picurl', '') @property def message_id(self) -> Optional[str]: """ 消息 ID,仅在消息类型事件中存在。 Returns: Optional[str]: 消息 ID。 """ return self.get('msgid') @property def message(self) -> Optional[str]: """ 消息内容,仅在消息类型事件中存在。 Returns: Optional[str]: 消息内容。 """ if self.get('msgtype') == 'text': return self.get('text').get('content', '') else: return None @property def timestamp(self) -> Optional[int]: """ 事件发生的时间戳。 Returns: Optional[int]: 时间戳。 """ return self.get('send_time') def __getattr__(self, key: str) -> Optional[Any]: """ 允许通过属性访问数据中的任意字段。 Args: key (str): 字段名。 Returns: Optional[Any]: 字段值。 """ return self.get(key) def __setattr__(self, key: str, value: Any) -> None: """ 允许通过属性设置数据中的任意字段。 Args: key (str): 字段名。 value (Any): 字段值。 """ self[key] = value def __repr__(self) -> str: """ 生成事件对象的字符串表示。 Returns: str: 字符串表示。 """ return f'' ================================================ FILE: src/langbot/pkg/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/api/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/api/http/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/api/http/controller/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/api/http/controller/group.py ================================================ from __future__ import annotations import abc import typing import enum import quart import traceback from quart.typing import RouteCallable from ....core import app # Maximum file upload size limit (10MB) MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB preregistered_groups: list[type[RouterGroup]] = [] """Pre-registered list of RouterGroup""" def group_class(name: str, path: str) -> typing.Callable[[typing.Type[RouterGroup]], typing.Type[RouterGroup]]: """注册一个 RouterGroup""" def decorator(cls: typing.Type[RouterGroup]) -> typing.Type[RouterGroup]: cls.name = name cls.path = path preregistered_groups.append(cls) return cls return decorator class AuthType(enum.Enum): """Authentication type""" NONE = 'none' USER_TOKEN = 'user-token' API_KEY = 'api-key' USER_TOKEN_OR_API_KEY = 'user-token-or-api-key' class RouterGroup(abc.ABC): name: str path: str ap: app.Application quart_app: quart.Quart def __init__(self, ap: app.Application, quart_app: quart.Quart) -> None: self.ap = ap self.quart_app = quart_app @abc.abstractmethod async def initialize(self) -> None: pass def route( self, rule: str, auth_type: AuthType = AuthType.USER_TOKEN, **options: typing.Any, ) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator """Register a route""" def decorator(f: RouteCallable) -> RouteCallable: nonlocal rule rule = self.path + rule async def handler_error(*args, **kwargs): if auth_type == AuthType.USER_TOKEN: # get token from Authorization header token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') if not token: return self.http_status(401, -1, 'No valid user token provided') try: user_email = await self.ap.user_service.verify_jwt_token(token) # check if this account exists user = await self.ap.user_service.get_user_by_email(user_email) if not user: return self.http_status(401, -1, 'User not found') # check if f accepts user_email parameter if 'user_email' in f.__code__.co_varnames: kwargs['user_email'] = user_email except Exception as e: return self.http_status(401, -1, str(e)) elif auth_type == AuthType.API_KEY: # get API key from Authorization header or X-API-Key header api_key = quart.request.headers.get('X-API-Key', '') if not api_key: auth_header = quart.request.headers.get('Authorization', '') if auth_header.startswith('Bearer '): api_key = auth_header.replace('Bearer ', '') if not api_key: return self.http_status(401, -1, 'No valid API key provided') try: is_valid = await self.ap.apikey_service.verify_api_key(api_key) if not is_valid: return self.http_status(401, -1, 'Invalid API key') except Exception as e: return self.http_status(401, -1, str(e)) elif auth_type == AuthType.USER_TOKEN_OR_API_KEY: # Try API key first (check X-API-Key header) api_key = quart.request.headers.get('X-API-Key', '') if api_key: # API key authentication try: is_valid = await self.ap.apikey_service.verify_api_key(api_key) if not is_valid: return self.http_status(401, -1, 'Invalid API key') except Exception as e: return self.http_status(401, -1, str(e)) else: # Try user token authentication (Authorization header) token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') if not token: return self.http_status( 401, -1, 'No valid authentication provided (user token or API key required)' ) try: user_email = await self.ap.user_service.verify_jwt_token(token) # check if this account exists user = await self.ap.user_service.get_user_by_email(user_email) if not user: return self.http_status(401, -1, 'User not found') # check if f accepts user_email parameter if 'user_email' in f.__code__.co_varnames: kwargs['user_email'] = user_email except Exception: # If user token fails, maybe it's an API key in Authorization header try: is_valid = await self.ap.apikey_service.verify_api_key(token) if not is_valid: return self.http_status(401, -1, 'Invalid authentication credentials') except Exception as e: return self.http_status(401, -1, str(e)) try: return await f(*args, **kwargs) except Exception as e: # 自动 500 traceback.print_exc() # return self.http_status(500, -2, str(e)) return self.http_status(500, -2, str(e)) new_f = handler_error new_f.__name__ = (self.name + rule).replace('/', '__') new_f.__doc__ = f.__doc__ self.quart_app.route(rule, **options)(new_f) return f return decorator def success(self, data: typing.Any = None) -> quart.Response: """Return a 200 response""" return quart.jsonify( { 'code': 0, 'msg': 'ok', 'data': data, } ) def fail(self, code: int, msg: str) -> quart.Response: """Return an error response""" return quart.jsonify( { 'code': code, 'msg': msg, } ) def http_status(self, status: int, code: int, msg: str) -> typing.Tuple[quart.Response, int]: """返回一个指定状态码的响应""" return (self.fail(code, msg), status) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/api/http/controller/groups/apikeys.py ================================================ import quart from .. import group @group.group_class('apikeys', '/api/v1/apikeys') class ApiKeysRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET', 'POST']) async def _() -> str: if quart.request.method == 'GET': keys = await self.ap.apikey_service.get_api_keys() return self.success(data={'keys': keys}) elif quart.request.method == 'POST': json_data = await quart.request.json name = json_data.get('name', '') description = json_data.get('description', '') if not name: return self.http_status(400, -1, 'Name is required') key = await self.ap.apikey_service.create_api_key(name, description) return self.success(data={'key': key}) @self.route('/', methods=['GET', 'PUT', 'DELETE']) async def _(key_id: int) -> str: if quart.request.method == 'GET': key = await self.ap.apikey_service.get_api_key(key_id) if key is None: return self.http_status(404, -1, 'API key not found') return self.success(data={'key': key}) elif quart.request.method == 'PUT': json_data = await quart.request.json name = json_data.get('name') description = json_data.get('description') await self.ap.apikey_service.update_api_key(key_id, name, description) return self.success() elif quart.request.method == 'DELETE': await self.ap.apikey_service.delete_api_key(key_id) return self.success() ================================================ FILE: src/langbot/pkg/api/http/controller/groups/files.py ================================================ from __future__ import annotations import quart import mimetypes import uuid import asyncio import quart.datastructures from .. import group @group.group_class('files', '/api/v1/files') class FilesRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('/image/', methods=['GET'], auth_type=group.AuthType.NONE) async def _(image_key: str) -> quart.Response: if '/' in image_key or '\\' in image_key: return quart.Response(status=404) if not await self.ap.storage_mgr.storage_provider.exists(image_key): return quart.Response(status=404) image_bytes = await self.ap.storage_mgr.storage_provider.load(image_key) mime_type = mimetypes.guess_type(image_key)[0] if mime_type is None: mime_type = 'image/jpeg' return quart.Response(image_bytes, mimetype=mime_type) @self.route('/images', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def upload_image() -> quart.Response: request = quart.request # Check file size limit before reading the file content_length = request.content_length if content_length and content_length > group.MAX_FILE_SIZE: return self.fail(400, 'Image size exceeds 10MB limit.') # get file bytes from 'file' files = await request.files if 'file' not in files: return self.fail(400, 'No image file provided') file = files['file'] assert isinstance(file, quart.datastructures.FileStorage) file_bytes = await asyncio.to_thread(file.stream.read) # Double-check actual file size after reading if len(file_bytes) > group.MAX_FILE_SIZE: return self.fail(400, 'Image size exceeds 10MB limit.') # Validate image file extension allowed_extensions = {'jpg', 'jpeg', 'png', 'gif', 'webp'} if '.' in file.filename: file_name, extension = file.filename.rsplit('.', 1) extension = extension.lower() else: return self.fail(400, 'Invalid image file: no file extension') if extension not in allowed_extensions: return self.fail(400, f'Invalid image format. Allowed formats: {", ".join(allowed_extensions)}') # check if file name contains '/' or '\' if '/' in file_name or '\\' in file_name: return self.fail(400, 'File name contains invalid characters') file_key = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension # save file to storage await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes) return self.success( data={ 'file_key': file_key, } ) @self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def upload_document() -> quart.Response: request = quart.request # Check file size limit before reading the file content_length = request.content_length if content_length and content_length > group.MAX_FILE_SIZE: return self.fail(400, 'File size exceeds 10MB limit. Please split large files into smaller parts.') # get file bytes from 'file' files = await request.files if 'file' not in files: return self.fail(400, 'No file provided in request') file = files['file'] assert isinstance(file, quart.datastructures.FileStorage) file_bytes = await asyncio.to_thread(file.stream.read) # Double-check actual file size after reading if len(file_bytes) > group.MAX_FILE_SIZE: return self.fail(400, 'File size exceeds 10MB limit. Please split large files into smaller parts.') # Split filename and extension properly if '.' in file.filename: file_name, extension = file.filename.rsplit('.', 1) else: file_name = file.filename extension = '' # check if file name contains '/' or '\' if '/' in file_name or '\\' in file_name: return self.fail(400, 'File name contains invalid characters') file_key = file_name + '_' + str(uuid.uuid4())[:8] if extension: file_key += '.' + extension # save file to storage await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes) return self.success( data={ 'file_id': file_key, } ) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/knowledge/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/api/http/controller/groups/knowledge/base.py ================================================ import quart from ... import group @group.group_class('knowledge_base', '/api/v1/knowledge/bases') class KnowledgeBaseRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['POST', 'GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def handle_knowledge_bases() -> quart.Response: if quart.request.method == 'GET': knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases() return self.success(data={'bases': knowledge_bases}) elif quart.request.method == 'POST': json_data = await quart.request.json try: knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(json_data) except ValueError as e: return self.http_status(400, -1, str(e)) return self.success(data={'uuid': knowledge_base_uuid}) return self.http_status(405, -1, 'Method not allowed') @self.route( '/', methods=['GET', 'DELETE', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def handle_specific_knowledge_base(knowledge_base_uuid: str) -> quart.Response: if quart.request.method == 'GET': knowledge_base = await self.ap.knowledge_service.get_knowledge_base(knowledge_base_uuid) if knowledge_base is None: return self.http_status(404, -1, 'knowledge base not found') return self.success( data={ 'base': knowledge_base, } ) elif quart.request.method == 'PUT': json_data = await quart.request.json await self.ap.knowledge_service.update_knowledge_base(knowledge_base_uuid, json_data) return self.success(data={'uuid': knowledge_base_uuid}) elif quart.request.method == 'DELETE': await self.ap.knowledge_service.delete_knowledge_base(knowledge_base_uuid) return self.success({}) @self.route( '//files', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def get_knowledge_base_files(knowledge_base_uuid: str) -> str: if quart.request.method == 'GET': files = await self.ap.knowledge_service.get_files_by_knowledge_base(knowledge_base_uuid) return self.success( data={ 'files': files, } ) elif quart.request.method == 'POST': json_data = await quart.request.json file_id = json_data.get('file_id') if not file_id: return self.http_status(400, -1, 'File ID is required') parser_plugin_id = json_data.get('parser_plugin_id') # 调用服务层方法将文件与知识库关联 task_id = await self.ap.knowledge_service.store_file( knowledge_base_uuid, file_id, parser_plugin_id=parser_plugin_id ) return self.success( { 'task_id': task_id, } ) @self.route( '//files/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def delete_specific_file_in_kb(file_id: str, knowledge_base_uuid: str) -> str: await self.ap.knowledge_service.delete_file(knowledge_base_uuid, file_id) return self.success({}) @self.route( '//retrieve', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def retrieve_knowledge_base(knowledge_base_uuid: str) -> str: json_data = await quart.request.json query = json_data.get('query') if not query or not query.strip(): return self.http_status(400, -1, 'Query is required and cannot be empty') # Extract retrieval_settings to allow dynamic control over Knowledge Engine behavior (e.g. top_k, filters) retrieval_settings = json_data.get('retrieval_settings', {}) results = await self.ap.knowledge_service.retrieve_knowledge_base( knowledge_base_uuid, query, retrieval_settings ) return self.success(data={'results': results}) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/knowledge/engines.py ================================================ import quart from urllib.parse import unquote from ... import group @group.group_class('knowledge_engines', '/api/v1/knowledge/engines') class KnowledgeEnginesRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def list_knowledge_engines() -> quart.Response: """List all available Knowledge Engines from plugins. Returns a list of Knowledge Engines with their capabilities and configuration schemas. This is used by the frontend to render the knowledge base creation wizard. """ engines = await self.ap.knowledge_service.list_knowledge_engines() return self.success(data={'engines': engines}) @self.route( '//creation-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY ) async def get_engine_creation_schema(plugin_id: str) -> quart.Response: """Get creation settings schema for a specific Knowledge Engine. plugin_id is in 'author/name' format, captured via converter. """ plugin_id = unquote(plugin_id) if '/' not in plugin_id: return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.') schema = await self.ap.knowledge_service.get_engine_creation_schema(plugin_id) return self.success(data={'schema': schema}) @self.route( '//retrieval-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY ) async def get_engine_retrieval_schema(plugin_id: str) -> quart.Response: """Get retrieval settings schema for a specific Knowledge Engine. plugin_id is in 'author/name' format, captured via converter. """ plugin_id = unquote(plugin_id) if '/' not in plugin_id: return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.') schema = await self.ap.knowledge_service.get_engine_retrieval_schema(plugin_id) return self.success(data={'schema': schema}) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/knowledge/migration.py ================================================ import asyncio import json import httpx import quart import sqlalchemy from ... import group from ......core import taskmgr from ......entity.persistence import metadata as persistence_metadata from langbot_plugin.runtime.plugin.mgr import PluginInstallSource LANGRAG_PLUGIN_AUTHOR = 'langbot-team' LANGRAG_PLUGIN_NAME = 'LangRAG' LANGRAG_PLUGIN_ID = f'{LANGRAG_PLUGIN_AUTHOR}/{LANGRAG_PLUGIN_NAME}' DEFAULT_SPACE_URL = 'https://space.langbot.app' # Old Retriever plugin_name -> New Connector plugin_name EXTERNAL_PLUGIN_NAME_MAPPING = { 'DifyDatasetsRetriever': 'DifyDatasetsConnector', 'RAGFlowRetriever': 'RAGFlowConnector', 'FastGPTRetriever': 'FastGPTConnector', } # Per-plugin: which old retriever_config fields belong to creation_settings. # Remaining fields go to retrieval_settings. # None means ALL fields go to creation_settings (no retrieval_schema). EXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = { 'langbot-team/DifyDatasetsConnector': {'api_base_url', 'dify_apikey', 'dataset_id'}, 'langbot-team/RAGFlowConnector': {'api_base_url', 'api_key', 'dataset_ids'}, 'langbot-team/FastGPTConnector': None, # all fields -> creation_settings } @group.group_class('knowledge/migration', '/api/v1/knowledge/migration') class KnowledgeMigrationRouterGroup(group.RouterGroup): async def _get_migration_flag(self) -> bool: """Check if rag_plugin_migration_needed flag is set.""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_metadata.Metadata).where( persistence_metadata.Metadata.key == 'rag_plugin_migration_needed' ) ) row = result.first() return row is not None and row.value == 'true' async def _set_migration_flag(self, value: str): """Set rag_plugin_migration_needed flag.""" await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_metadata.Metadata) .where(persistence_metadata.Metadata.key == 'rag_plugin_migration_needed') .values(value=value) ) async def _table_exists(self, table_name: str) -> bool: """Check if a table exists.""" if self.ap.persistence_mgr.db.name == 'postgresql': result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);' ).bindparams(table_name=table_name) ) return result.scalar() else: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams( table_name=table_name ) ) return result.first() is not None async def _install_plugin_from_marketplace( self, plugin_id: str, task_context: taskmgr.TaskContext, space_url: str ) -> None: """Install a single plugin from the marketplace.""" p_author, p_name = plugin_id.split('/', 1) self.ap.logger.info(f'RAG migration: installing plugin {plugin_id} from marketplace...') task_context.trace(f'Installing plugin {plugin_id} from marketplace...') async with httpx.AsyncClient(trust_env=True, timeout=15) as client: resp = await client.get(f'{space_url}/api/v1/marketplace/plugins/{p_author}/{p_name}') resp.raise_for_status() p_data = resp.json().get('data', {}).get('plugin', {}) p_version = p_data.get('latest_version') if not p_version: raise Exception(f'Could not determine latest version for {plugin_id}') await self.ap.plugin_connector.install_plugin( PluginInstallSource.MARKETPLACE, { 'plugin_author': p_author, 'plugin_name': p_name, 'plugin_version': p_version, }, task_context=task_context, ) self.ap.logger.info(f'RAG migration: plugin {plugin_id} install request sent.') async def _execute_rag_migration(self, task_context: taskmgr.TaskContext, install_plugin: bool = True): """Execute RAG migration: install required plugins and restore backup data.""" warnings = [] # Collect all plugins we need: LangRAG (always) + connector plugins (from external KBs) needed_plugins: dict[str, str] = { LANGRAG_PLUGIN_ID: LANGRAG_PLUGIN_NAME, } has_external = await self._table_exists('external_knowledge_bases') if has_external: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT DISTINCT plugin_author, plugin_name FROM external_knowledge_bases;') ) for row in result.fetchall(): plugin_author = row[0] or '' plugin_name = row[1] or '' mapped_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name) plugin_id = f'{plugin_author}/{mapped_name}' if plugin_id not in needed_plugins: needed_plugins[plugin_id] = mapped_name self.ap.logger.info(f'RAG migration: plugins needed: {list(needed_plugins.keys())}') if install_plugin: # Step 1: Install all required plugins from marketplace task_context.trace('Installing required plugins...', action='install-plugin') space_url = self.ap.instance_config.data.get('space', {}).get('url', DEFAULT_SPACE_URL).rstrip('/') for plugin_id in needed_plugins: try: await self._install_plugin_from_marketplace(plugin_id, task_context, space_url) except Exception as e: self.ap.logger.warning(f'RAG migration: plugin {plugin_id} install returned: {e}') task_context.trace(f'Plugin install note ({plugin_id}): {e}') # Step 2: Wait for all plugins to become available as knowledge engines task_context.trace( f'Waiting for plugins to become available: {list(needed_plugins.keys())}...', action='wait-plugin', ) max_retries = 30 engine_id_set: set[str] = set() for i in range(max_retries): try: engines = await self.ap.plugin_connector.list_knowledge_engines() engine_id_set = {e.get('plugin_id') for e in engines} except Exception: pass if all(pid in engine_id_set for pid in needed_plugins): self.ap.logger.info(f'RAG migration: all plugins ready: {engine_id_set}') task_context.trace('All required plugins are ready.') break if i == max_retries - 1: still_missing = [pid for pid in needed_plugins if pid not in engine_id_set] warning = f'Plugin(s) {still_missing} did not become available after {max_retries} retries' self.ap.logger.warning(f'RAG migration: {warning}') warnings.append(warning) task_context.trace(warning) await asyncio.sleep(2) else: try: engines = await self.ap.plugin_connector.list_knowledge_engines() engine_id_set = {e.get('plugin_id') for e in engines} except Exception: engine_id_set = set() # Step 3: Restore internal knowledge bases from backup task_context.trace('Restoring internal knowledge bases...', action='restore-internal') if await self._table_exists('knowledge_bases_backup'): result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT * FROM knowledge_bases_backup;') ) rows = result.fetchall() columns = result.keys() for row in rows: row_dict = dict(zip(columns, row)) kb_uuid = row_dict.get('uuid') name = row_dict.get('name', 'Untitled') description = row_dict.get('description', '') emoji = row_dict.get('emoji', '\U0001f4da') embedding_model_uuid = row_dict.get('embedding_model_uuid', '') top_k = row_dict.get('top_k', 5) created_at = row_dict.get('created_at') updated_at = row_dict.get('updated_at') creation_settings = json.dumps({'embedding_model_uuid': embedding_model_uuid}) retrieval_settings = json.dumps({'top_k': top_k}) await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'INSERT INTO knowledge_bases ' '(uuid, name, description, emoji, created_at, updated_at, ' 'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) ' 'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, ' ':plugin_id, :collection_id, :creation_settings, :retrieval_settings);' ).bindparams( uuid=kb_uuid, name=name, description=description, emoji=emoji, created_at=created_at, updated_at=updated_at, plugin_id=LANGRAG_PLUGIN_ID, collection_id=kb_uuid, creation_settings=creation_settings, retrieval_settings=retrieval_settings, ) ) try: config = {'embedding_model_uuid': embedding_model_uuid} await self.ap.plugin_connector.rag_on_kb_create(LANGRAG_PLUGIN_ID, kb_uuid, config) task_context.trace(f'Restored internal KB: {name} ({kb_uuid})') except Exception as e: warning = f'Failed to notify plugin for KB {name} ({kb_uuid}): {e}' warnings.append(warning) task_context.trace(warning) await self.ap.rag_mgr.load_knowledge_bases_from_db() # Step 4: Restore external knowledge bases task_context.trace('Restoring external knowledge bases...', action='restore-external') if has_external: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT * FROM external_knowledge_bases;') ) rows = result.fetchall() columns = result.keys() self.ap.logger.info( f'RAG migration: {len(rows)} external KB(s) to restore. Available engines: {engine_id_set}' ) task_context.trace(f'Found {len(rows)} external KB(s). Available engines: {engine_id_set}') for row in rows: row_dict = dict(zip(columns, row)) kb_uuid = row_dict.get('uuid') name = row_dict.get('name', 'Untitled') description = row_dict.get('description', '') emoji = row_dict.get('emoji', '\U0001f517') plugin_author = row_dict.get('plugin_author', '') plugin_name = row_dict.get('plugin_name', '') retriever_config = row_dict.get('retriever_config', {}) created_at = row_dict.get('created_at') mapped_plugin_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name) external_plugin_id = f'{plugin_author}/{mapped_plugin_name}' self.ap.logger.info( f'RAG migration: processing external KB "{name}" ({kb_uuid}), ' f'plugin: {plugin_author}/{plugin_name} -> {external_plugin_id}' ) if isinstance(retriever_config, str): try: retriever_config = json.loads(retriever_config) except (json.JSONDecodeError, TypeError): retriever_config = {} creation_fields = EXTERNAL_PLUGIN_CREATION_FIELDS.get(external_plugin_id) if creation_fields is None: creation_settings_dict = retriever_config retrieval_settings_dict = {} else: creation_settings_dict = {k: v for k, v in retriever_config.items() if k in creation_fields} retrieval_settings_dict = {k: v for k, v in retriever_config.items() if k not in creation_fields} await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'INSERT INTO knowledge_bases ' '(uuid, name, description, emoji, created_at, updated_at, ' 'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) ' 'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, ' ':plugin_id, :collection_id, :creation_settings, :retrieval_settings);' ).bindparams( uuid=kb_uuid, name=name, description=description, emoji=emoji, created_at=created_at, updated_at=created_at, plugin_id=external_plugin_id, collection_id=kb_uuid, creation_settings=json.dumps(creation_settings_dict), retrieval_settings=json.dumps(retrieval_settings_dict), ) ) if external_plugin_id not in engine_id_set: warning = ( f'External KB "{name}" ({kb_uuid}) record saved, but plugin {external_plugin_id} ' f'is not installed yet. Install the connector plugin to use it.' ) warnings.append(warning) task_context.trace(warning) else: try: await self.ap.plugin_connector.rag_on_kb_create( external_plugin_id, kb_uuid, creation_settings_dict ) task_context.trace(f'Restored external KB: {name} ({kb_uuid})') except Exception as e: warning = f'Failed to notify plugin for external KB {name} ({kb_uuid}): {e}' warnings.append(warning) task_context.trace(warning) await self.ap.rag_mgr.load_knowledge_bases_from_db() # Step 5: Clear migration flag await self._set_migration_flag('false') task_context.trace('RAG migration completed.', action='done') if warnings: task_context.trace(f'Completed with {len(warnings)} warning(s).') async def initialize(self) -> None: @self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: needed = await self._get_migration_flag() internal_kb_count = 0 external_kb_count = 0 if needed: if await self._table_exists('knowledge_bases_backup'): result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases_backup;') ) internal_kb_count = result.scalar() or 0 if await self._table_exists('external_knowledge_bases'): result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;') ) external_kb_count = result.scalar() or 0 return self.success( data={ 'needed': needed, 'internal_kb_count': internal_kb_count, 'external_kb_count': external_kb_count, } ) @self.route('/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: needed = await self._get_migration_flag() if not needed: return self.http_status(400, -1, 'RAG migration is not needed') data = await quart.request.get_json(silent=True) or {} install_plugin = data.get('install_plugin', True) ctx = taskmgr.TaskContext.new() wrapper = self.ap.task_mgr.create_user_task( self._execute_rag_migration(task_context=ctx, install_plugin=install_plugin), kind='rag-migration', name='rag-migration-execute', label='Migrating knowledge bases to plugin architecture', context=ctx, ) return self.success(data={'task_id': wrapper.id}) @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: needed = await self._get_migration_flag() if not needed: return self.http_status(400, -1, 'RAG migration is not needed') await self._set_migration_flag('false') return self.success() ================================================ FILE: src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py ================================================ import quart from ... import group @group.group_class('parsers', '/api/v1/knowledge/parsers') class ParsersRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def list_parsers() -> quart.Response: """List all available parsers from plugins. Optional query parameter `mime_type` to filter parsers by supported MIME type. """ mime_type = quart.request.args.get('mime_type') parsers = await self.ap.knowledge_service.list_parsers(mime_type) return self.success(data={'parsers': parsers}) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/logs.py ================================================ from __future__ import annotations import quart from .. import group @group.group_class('logs', '/api/v1/logs') class LogsRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: start_page_number = int(quart.request.args.get('start_page_number', 0)) start_offset = int(quart.request.args.get('start_offset', 0)) logs_str, end_page_number, end_offset = self.ap.log_cache.get_log_by_pointer( start_page_number=start_page_number, start_offset=start_offset ) return self.success( data={ 'logs': logs_str, 'end_page_number': end_page_number, 'end_offset': end_offset, } ) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/monitoring.py ================================================ from __future__ import annotations import datetime import quart from .. import group def parse_iso_datetime(datetime_str: str | None) -> datetime.datetime | None: """Parse ISO 8601 datetime string, handling 'Z' suffix for UTC timezone""" if not datetime_str: return None # Replace 'Z' with '+00:00' for Python 3.10 compatibility if datetime_str.endswith('Z'): datetime_str = datetime_str[:-1] + '+00:00' dt = datetime.datetime.fromisoformat(datetime_str) # Convert to UTC and remove timezone info to match database storage (which stores UTC as naive datetime) if dt.tzinfo is not None: # Convert to UTC and remove timezone info dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None) return dt @group.group_class('monitoring', '/api/v1/monitoring') class MonitoringRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('/overview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_overview() -> str: """Get overview metrics""" # Parse query parameters bot_ids = quart.request.args.getlist('botId') pipeline_ids = quart.request.args.getlist('pipelineId') start_time_str = quart.request.args.get('startTime') end_time_str = quart.request.args.get('endTime') # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) metrics = await self.ap.monitoring_service.get_overview_metrics( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, ) return self.success(data=metrics) @self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_messages() -> str: """Get message logs""" # Parse query parameters bot_ids = quart.request.args.getlist('botId') pipeline_ids = quart.request.args.getlist('pipelineId') session_ids = quart.request.args.getlist('sessionId') start_time_str = quart.request.args.get('startTime') end_time_str = quart.request.args.get('endTime') limit = int(quart.request.args.get('limit', 100)) offset = int(quart.request.args.get('offset', 0)) # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) messages, total = await self.ap.monitoring_service.get_messages( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, session_ids=session_ids if session_ids else None, start_time=start_time, end_time=end_time, limit=limit, offset=offset, ) return self.success( data={ 'messages': messages, 'total': total, 'limit': limit, 'offset': offset, } ) @self.route('/llm-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_llm_calls() -> str: """Get LLM call records""" # Parse query parameters bot_ids = quart.request.args.getlist('botId') pipeline_ids = quart.request.args.getlist('pipelineId') start_time_str = quart.request.args.get('startTime') end_time_str = quart.request.args.get('endTime') limit = int(quart.request.args.get('limit', 100)) offset = int(quart.request.args.get('offset', 0)) # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) llm_calls, total = await self.ap.monitoring_service.get_llm_calls( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, limit=limit, offset=offset, ) return self.success( data={ 'llm_calls': llm_calls, 'total': total, 'limit': limit, 'offset': offset, } ) @self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_embedding_calls() -> str: """Get embedding call records""" # Parse query parameters start_time_str = quart.request.args.get('startTime') end_time_str = quart.request.args.get('endTime') knowledge_base_id = quart.request.args.get('knowledgeBaseId') limit = int(quart.request.args.get('limit', 100)) offset = int(quart.request.args.get('offset', 0)) # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) embedding_calls, total = await self.ap.monitoring_service.get_embedding_calls( start_time=start_time, end_time=end_time, knowledge_base_id=knowledge_base_id if knowledge_base_id else None, limit=limit, offset=offset, ) return self.success( data={ 'embedding_calls': embedding_calls, 'total': total, 'limit': limit, 'offset': offset, } ) @self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_sessions() -> str: """Get session information""" # Parse query parameters bot_ids = quart.request.args.getlist('botId') pipeline_ids = quart.request.args.getlist('pipelineId') start_time_str = quart.request.args.get('startTime') end_time_str = quart.request.args.get('endTime') is_active_str = quart.request.args.get('isActive') limit = int(quart.request.args.get('limit', 100)) offset = int(quart.request.args.get('offset', 0)) # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) # Parse is_active is_active = None if is_active_str: is_active = is_active_str.lower() == 'true' sessions, total = await self.ap.monitoring_service.get_sessions( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, is_active=is_active, limit=limit, offset=offset, ) return self.success( data={ 'sessions': sessions, 'total': total, 'limit': limit, 'offset': offset, } ) @self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_errors() -> str: """Get error logs""" # Parse query parameters bot_ids = quart.request.args.getlist('botId') pipeline_ids = quart.request.args.getlist('pipelineId') start_time_str = quart.request.args.get('startTime') end_time_str = quart.request.args.get('endTime') limit = int(quart.request.args.get('limit', 100)) offset = int(quart.request.args.get('offset', 0)) # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) errors, total = await self.ap.monitoring_service.get_errors( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, limit=limit, offset=offset, ) return self.success( data={ 'errors': errors, 'total': total, 'limit': limit, 'offset': offset, } ) @self.route('/data', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_all_data() -> str: """Get all monitoring data in a single request""" # Parse query parameters bot_ids = quart.request.args.getlist('botId') pipeline_ids = quart.request.args.getlist('pipelineId') start_time_str = quart.request.args.get('startTime') end_time_str = quart.request.args.get('endTime') limit = int(quart.request.args.get('limit', 50)) # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) # Get overview metrics overview = await self.ap.monitoring_service.get_overview_metrics( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, ) # Get messages messages, messages_total = await self.ap.monitoring_service.get_messages( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, limit=limit, offset=0, ) # Get LLM calls llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, limit=limit, offset=0, ) # Get sessions sessions, sessions_total = await self.ap.monitoring_service.get_sessions( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, is_active=None, limit=limit, offset=0, ) # Get errors errors, errors_total = await self.ap.monitoring_service.get_errors( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, limit=limit, offset=0, ) # Get embedding calls embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls( start_time=start_time, end_time=end_time, limit=limit, offset=0, ) return self.success( data={ 'overview': overview, 'messages': messages, 'llmCalls': llm_calls, 'embeddingCalls': embedding_calls, 'sessions': sessions, 'errors': errors, 'totalCount': { 'messages': messages_total, 'llmCalls': llm_calls_total, 'embeddingCalls': embedding_calls_total, 'sessions': sessions_total, 'errors': errors_total, }, } ) @self.route('/sessions//analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_session_analysis(session_id: str) -> str: """Get detailed analysis for a specific session""" analysis = await self.ap.monitoring_service.get_session_analysis(session_id) # Always return success with the analysis data # The frontend will handle the 'found: false' case return self.success(data=analysis) @self.route('/messages//details', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_message_details(message_id: str) -> str: """Get detailed information for a specific message""" details = await self.ap.monitoring_service.get_message_details(message_id) if not details.get('found'): return self.error(message=f'Message {message_id} not found', code=404) return self.success(data=details) @self.route('/export', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def export_data() -> tuple[str, int]: """Export monitoring data as CSV""" # Parse query parameters export_type = quart.request.args.get('type', 'messages') bot_ids = quart.request.args.getlist('botId') pipeline_ids = quart.request.args.getlist('pipelineId') start_time_str = quart.request.args.get('startTime') end_time_str = quart.request.args.get('endTime') limit = int(quart.request.args.get('limit', 100000)) # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) # Get data based on export type if export_type == 'messages': data = await self.ap.monitoring_service.export_messages( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, limit=limit, ) headers = [ 'id', 'timestamp', 'bot_id', 'bot_name', 'pipeline_id', 'pipeline_name', 'runner_name', 'message_content', 'message_text', 'session_id', 'status', 'level', 'platform', 'user_id', ] elif export_type == 'llm-calls': data = await self.ap.monitoring_service.export_llm_calls( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, limit=limit, ) headers = [ 'id', 'timestamp', 'model_name', 'input_tokens', 'output_tokens', 'total_tokens', 'duration_ms', 'cost', 'status', 'bot_id', 'bot_name', 'pipeline_id', 'pipeline_name', 'session_id', 'message_id', 'error_message', ] elif export_type == 'embedding-calls': data = await self.ap.monitoring_service.export_embedding_calls( start_time=start_time, end_time=end_time, limit=limit, ) headers = [ 'id', 'timestamp', 'model_name', 'prompt_tokens', 'total_tokens', 'duration_ms', 'input_count', 'status', 'error_message', 'knowledge_base_id', 'query_text', 'session_id', 'message_id', 'call_type', ] elif export_type == 'errors': data = await self.ap.monitoring_service.export_errors( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, limit=limit, ) headers = [ 'id', 'timestamp', 'error_type', 'error_message', 'bot_id', 'bot_name', 'pipeline_id', 'pipeline_name', 'session_id', 'message_id', 'stack_trace', ] elif export_type == 'sessions': data = await self.ap.monitoring_service.export_sessions( bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, limit=limit, ) headers = [ 'session_id', 'bot_id', 'bot_name', 'pipeline_id', 'pipeline_name', 'message_count', 'start_time', 'last_activity', 'is_active', 'platform', 'user_id', ] else: return self.error(message=f'Invalid export type: {export_type}', code=400) # Generate CSV content with UTF-8 BOM for Excel compatibility import io output = io.StringIO() # Write UTF-8 BOM for Excel output.write('\ufeff') # Write header output.write(','.join(headers) + '\n') # Escape and write each row for row in data: escaped_values = [] for header in headers: value = row.get(header, '') escaped_values.append(self.ap.monitoring_service._escape_csv_field(value)) output.write(','.join(escaped_values) + '\n') csv_content = output.getvalue() # Return as file download response = await quart.make_response(csv_content) response.headers['Content-Type'] = 'text/csv; charset=utf-8' response.headers['Content-Disposition'] = ( f'attachment; filename="monitoring-{export_type}-{int(datetime.datetime.now().timestamp())}.csv"' ) return response, 200 ================================================ FILE: src/langbot/pkg/api/http/controller/groups/pipelines/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py ================================================ from __future__ import annotations import quart from ... import group @group.group_class('pipelines', '/api/v1/pipelines') class PipelinesRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': sort_by = quart.request.args.get('sort_by', 'created_at') sort_order = quart.request.args.get('sort_order', 'DESC') return self.success( data={'pipelines': await self.ap.pipeline_service.get_pipelines(sort_by, sort_order)} ) elif quart.request.method == 'POST': json_data = await quart.request.json pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data) return self.success(data={'uuid': pipeline_uuid}) @self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()}) @self.route( '/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY ) async def _(pipeline_uuid: str) -> str: if quart.request.method == 'GET': pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) if pipeline is None: return self.http_status(404, -1, 'pipeline not found') return self.success(data={'pipeline': pipeline}) elif quart.request.method == 'PUT': json_data = await quart.request.json await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data) return self.success() elif quart.request.method == 'DELETE': await self.ap.pipeline_service.delete_pipeline(pipeline_uuid) return self.success() @self.route('//copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(pipeline_uuid: str) -> str: try: new_uuid = await self.ap.pipeline_service.copy_pipeline(pipeline_uuid) return self.success(data={'uuid': new_uuid}) except ValueError as e: return self.http_status(404, -1, str(e)) @self.route( '//extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY ) async def _(pipeline_uuid: str) -> str: if quart.request.method == 'GET': # Get current extensions and available plugins pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) if pipeline is None: return self.http_status(404, -1, 'pipeline not found') # Only include plugins with pipeline-related components (Command, EventListener, Tool) # Plugins that only have KnowledgeEngine components are not suitable for pipeline extensions pipeline_component_kinds = ['Command', 'EventListener', 'Tool'] plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds) mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) extensions_prefs = pipeline.get('extensions_preferences', {}) return self.success( data={ 'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True), 'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True), 'bound_plugins': extensions_prefs.get('plugins', []), 'available_plugins': plugins, 'bound_mcp_servers': extensions_prefs.get('mcp_servers', []), 'available_mcp_servers': mcp_servers, } ) elif quart.request.method == 'PUT': # Update bound plugins and MCP servers for this pipeline json_data = await quart.request.json enable_all_plugins = json_data.get('enable_all_plugins', True) enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True) bound_plugins = json_data.get('bound_plugins', []) bound_mcp_servers = json_data.get('bound_mcp_servers', []) await self.ap.pipeline_service.update_pipeline_extensions( pipeline_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers ) return self.success() ================================================ FILE: src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py ================================================ """WebSocket聊天路由 - 支持双向实时通信""" import asyncio import datetime import json import logging import quart from ... import group from ......platform.sources.websocket_manager import ws_connection_manager logger = logging.getLogger(__name__) @group.group_class('websocket_chat', '/api/v1/pipelines//ws') class WebSocketChatRouterGroup(group.RouterGroup): async def initialize(self) -> None: # 直接使用 quart_app 注册 WebSocket 路由 @self.quart_app.websocket(self.path + '/connect') async def websocket_connect(pipeline_uuid: str): """ 建立WebSocket连接 URL参数: - pipeline_uuid: 流水线UUID - session_type: 会话类型 (person/group) """ try: # 获取参数 - 在WebSocket上下文中使用 quart.websocket.args session_type = quart.websocket.args.get('session_type', 'person') if session_type not in ['person', 'group']: await quart.websocket.send( json.dumps({'type': 'error', 'message': 'session_type must be person or group'}) ) return # 获取WebSocket适配器 websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter if not websocket_adapter: await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) return # 注册连接 connection = await ws_connection_manager.add_connection( websocket=quart.websocket._get_current_object(), pipeline_uuid=pipeline_uuid, session_type=session_type, metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')}, ) # 发送连接成功消息 await quart.websocket.send( json.dumps( { 'type': 'connected', 'connection_id': connection.connection_id, 'pipeline_uuid': pipeline_uuid, 'session_type': session_type, 'timestamp': connection.created_at.isoformat(), } ) ) logger.debug( f'WebSocket connection established: {connection.connection_id} ' f'(pipeline={pipeline_uuid}, session_type={session_type})' ) # 创建接收和发送任务 receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter)) send_task = asyncio.create_task(self._handle_send(connection)) # 等待任务完成 try: await asyncio.gather(receive_task, send_task) except Exception as e: logger.error(f'WebSocket task execution error: {e}') finally: # 清理连接 await ws_connection_manager.remove_connection(connection.connection_id) logger.debug(f'WebSocket connection cleaned: {connection.connection_id}') except Exception as e: logger.error(f'WebSocket connection error: {e}', exc_info=True) try: await quart.websocket.send(json.dumps({'type': 'error', 'message': str(e)})) except: pass @self.route('/messages/', methods=['GET']) async def get_messages(pipeline_uuid: str, session_type: str) -> str: """获取消息历史""" try: if session_type not in ['person', 'group']: return self.http_status(400, -1, 'session_type must be person or group') websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter if not websocket_adapter: return self.http_status(404, -1, 'WebSocket adapter not found') messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type) return self.success(data={'messages': messages}) except Exception as e: return self.http_status(500, -1, f'Internal server error: {str(e)}') @self.route('/reset/', methods=['POST']) async def reset_session(pipeline_uuid: str, session_type: str) -> str: """重置会话""" try: if session_type not in ['person', 'group']: return self.http_status(400, -1, 'session_type must be person or group') websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter if not websocket_adapter: return self.http_status(404, -1, 'WebSocket adapter not found') websocket_adapter.reset_session(pipeline_uuid, session_type) return self.success(data={'message': 'Session reset successfully'}) except Exception as e: return self.http_status(500, -1, f'Internal server error: {str(e)}') @self.route('/connections', methods=['GET']) async def get_connections(pipeline_uuid: str) -> str: """获取当前连接统计""" try: stats = ws_connection_manager.get_stats() connections = await ws_connection_manager.get_connections_by_pipeline(pipeline_uuid) return self.success( data={ 'stats': stats, 'connections': [ { 'connection_id': conn.connection_id, 'session_type': conn.session_type, 'created_at': conn.created_at.isoformat(), 'last_active': conn.last_active.isoformat(), 'is_active': conn.is_active, } for conn in connections ], } ) except Exception as e: return self.http_status(500, -1, f'Internal server error: {str(e)}') @self.route('/broadcast', methods=['POST']) async def broadcast_message(pipeline_uuid: str) -> str: """向所有连接广播消息(后端主动推送)""" try: data = await quart.request.get_json() message = data.get('message') if not message: return self.http_status(400, -1, 'message is required') # 广播消息 broadcast_data = { 'type': 'broadcast', 'message': message, 'timestamp': datetime.datetime.now().isoformat(), } await ws_connection_manager.broadcast_to_pipeline(pipeline_uuid, broadcast_data) return self.success(data={'message': 'Broadcast sent successfully'}) except Exception as e: return self.http_status(500, -1, f'Internal server error: {str(e)}') async def _handle_receive(self, connection, websocket_adapter): """处理接收消息的任务""" try: while connection.is_active: # 接收消息 message = await quart.websocket.receive() # 更新活跃时间 await ws_connection_manager.update_activity(connection.connection_id) try: data = json.loads(message) message_type = data.get('type', 'message') if message_type == 'ping': # 心跳响应 await connection.send_queue.put( {'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()} ) elif message_type == 'message': # 处理用户消息 logger.debug(f'收到消息: {data} from {connection.connection_id}') # 处理消息(不等待响应,响应会通过broadcast异步发送) await websocket_adapter.handle_websocket_message(connection, data) elif message_type == 'disconnect': # 客户端主动断开 logger.debug(f'Client disconnected: {connection.connection_id}') break else: logger.warning(f'Unknown message type: {message_type}') except json.JSONDecodeError: logger.error(f'Invalid JSON message: {message}') await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'}) except Exception as e: logger.error(f'Receive message error: {e}', exc_info=True) finally: connection.is_active = False async def _handle_send(self, connection): """处理发送消息的任务""" try: while connection.is_active: # 从队列获取消息 try: message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0) # 发送消息 await quart.websocket.send(json.dumps(message)) except asyncio.TimeoutError: # 超时继续循环 continue except Exception as e: logger.error(f'Send message error: {e}', exc_info=True) finally: connection.is_active = False ================================================ FILE: src/langbot/pkg/api/http/controller/groups/platform/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/api/http/controller/groups/platform/adapters.py ================================================ import quart import mimetypes from ... import group from langbot.pkg.utils import importutil @group.group_class('adapters', '/api/v1/platform/adapters') class AdaptersRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET']) async def _() -> str: return self.success(data={'adapters': self.ap.platform_mgr.get_available_adapters_info()}) @self.route('/', methods=['GET']) async def _(adapter_name: str) -> str: adapter_info = self.ap.platform_mgr.get_available_adapter_info_by_name(adapter_name) if adapter_info is None: return self.http_status(404, -1, 'adapter not found') return self.success(data={'adapter': adapter_info}) @self.route('//icon', methods=['GET'], auth_type=group.AuthType.NONE) async def _(adapter_name: str) -> quart.Response: adapter_manifest = self.ap.platform_mgr.get_available_adapter_manifest_by_name(adapter_name) if adapter_manifest is None: return self.http_status(404, -1, 'adapter not found') icon_path = adapter_manifest.icon_rel_path if icon_path is None: return self.http_status(404, -1, 'icon not found') return quart.Response( importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0] ) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/platform/bots.py ================================================ import quart from ... import group @group.group_class('bots', '/api/v1/platform/bots') class BotsRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': return self.success(data={'bots': await self.ap.bot_service.get_bots()}) elif quart.request.method == 'POST': json_data = await quart.request.json bot_uuid = await self.ap.bot_service.create_bot(json_data) return self.success(data={'uuid': bot_uuid}) @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(bot_uuid: str) -> str: if quart.request.method == 'GET': # 返回运行时信息,包括webhook地址等 bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid) if bot is None: return self.http_status(404, -1, 'bot not found') return self.success(data={'bot': bot}) elif quart.request.method == 'PUT': json_data = await quart.request.json await self.ap.bot_service.update_bot(bot_uuid, json_data) return self.success() elif quart.request.method == 'DELETE': await self.ap.bot_service.delete_bot(bot_uuid) return self.success() @self.route('//logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(bot_uuid: str) -> str: json_data = await quart.request.json from_index = json_data.get('from_index', -1) max_count = json_data.get('max_count', 10) logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count) return self.success( data={ 'logs': logs, 'total_count': total_count, } ) @self.route('//send_message', methods=['POST'], auth_type=group.AuthType.API_KEY) async def _(bot_uuid: str) -> str: """Send message to a specific target via bot""" json_data = await quart.request.json target_type = json_data.get('target_type') target_id = json_data.get('target_id') message_chain_data = json_data.get('message_chain') # Validate required fields if not target_type: return self.http_status(400, -1, 'target_type is required') if not target_id: return self.http_status(400, -1, 'target_id is required') if not message_chain_data: return self.http_status(400, -1, 'message_chain is required') # Validate target_type if target_type not in ['person', 'group']: return self.http_status(400, -1, 'target_type must be either "person" or "group"') try: await self.ap.bot_service.send_message(bot_uuid, target_type, target_id, message_chain_data) return self.success(data={'sent': True}) except Exception as e: import traceback traceback.print_exc() return self.http_status(500, -1, f'Failed to send message: {str(e)}') ================================================ FILE: src/langbot/pkg/api/http/controller/groups/plugins.py ================================================ from __future__ import annotations import base64 import quart import re import httpx import uuid import os from .....core import taskmgr from .. import group from langbot_plugin.runtime.plugin.mgr import PluginInstallSource @group.group_class('plugins', '/api/v1/plugins') class PluginsRouterGroup(group.RouterGroup): async def _check_extensions_limit(self) -> str | None: """Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise.""" limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) max_extensions = limitation.get('max_extensions', -1) if max_extensions >= 0: plugins = await self.ap.plugin_connector.list_plugins() mcp_servers = await self.ap.mcp_service.get_mcp_servers() total_extensions = len(plugins) + len(mcp_servers) if total_extensions >= max_extensions: return self.http_status(400, -1, f'Maximum number of extensions ({max_extensions}) reached') return None async def initialize(self) -> None: @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: plugins = await self.ap.plugin_connector.list_plugins() return self.success(data={'plugins': plugins}) @self.route('/debug-info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: """Get plugin debug information including debug URL and key""" debug_info = await self.ap.plugin_connector.get_debug_info() # Get debug URL from config plugin_config = self.ap.instance_config.data.get('plugin', {}) debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401') return self.success( data={ 'debug_url': debug_url, 'plugin_debug_key': debug_info.get('plugin_debug_key', ''), } ) @self.route( '///upgrade', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _(author: str, plugin_name: str) -> str: ctx = taskmgr.TaskContext.new() wrapper = self.ap.task_mgr.create_user_task( self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx), kind='plugin-operation', name=f'plugin-upgrade-{plugin_name}', label=f'Upgrading plugin {plugin_name}', context=ctx, ) return self.success(data={'task_id': wrapper.id}) @self.route( '//', methods=['GET', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _(author: str, plugin_name: str) -> str: if quart.request.method == 'GET': plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) if plugin is None: return self.http_status(404, -1, 'plugin not found') return self.success(data={'plugin': plugin}) elif quart.request.method == 'DELETE': delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true' ctx = taskmgr.TaskContext.new() wrapper = self.ap.task_mgr.create_user_task( self.ap.plugin_connector.delete_plugin( author, plugin_name, delete_data=delete_data, task_context=ctx ), kind='plugin-operation', name=f'plugin-remove-{plugin_name}', label=f'Removing plugin {plugin_name}', context=ctx, ) return self.success(data={'task_id': wrapper.id}) @self.route( '///config', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _(author: str, plugin_name: str) -> quart.Response: plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) if plugin is None: return self.http_status(404, -1, 'plugin not found') if quart.request.method == 'GET': return self.success(data={'config': plugin['plugin_config']}) elif quart.request.method == 'PUT': data = await quart.request.json await self.ap.plugin_connector.set_plugin_config(author, plugin_name, data) return self.success(data={}) @self.route( '///readme', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _(author: str, plugin_name: str) -> quart.Response: language = quart.request.args.get('language', 'en') readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language) return self.success(data={'readme': readme}) @self.route( '///icon', methods=['GET'], auth_type=group.AuthType.NONE, ) async def _(author: str, plugin_name: str) -> quart.Response: icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) icon_base64 = icon_data['plugin_icon_base64'] mime_type = icon_data['mime_type'] icon_data = base64.b64decode(icon_base64) return quart.Response(icon_data, mimetype=mime_type) @self.route( '///assets/', methods=['GET'], auth_type=group.AuthType.NONE, ) async def _(author: str, plugin_name: str, filepath: str) -> quart.Response: asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath) asset_bytes = base64.b64decode(asset_data['asset_base64']) mime_type = asset_data['mime_type'] return quart.Response(asset_bytes, mimetype=mime_type) @self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: """Get releases from a GitHub repository URL""" data = await quart.request.json repo_url = data.get('repo_url', '') # Parse GitHub repository URL to extract owner and repo # Supports: https://github.com/owner/repo or github.com/owner/repo pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$' match = re.search(pattern, repo_url) if not match: return self.http_status(400, -1, 'Invalid GitHub repository URL') owner, repo = match.groups() try: # Fetch releases from GitHub API url = f'https://api.github.com/repos/{owner}/{repo}/releases' async with httpx.AsyncClient( trust_env=True, follow_redirects=True, timeout=10, ) as client: response = await client.get(url) response.raise_for_status() releases = response.json() # Format releases data for frontend formatted_releases = [] for release in releases: formatted_releases.append( { 'id': release['id'], 'tag_name': release['tag_name'], 'name': release['name'], 'published_at': release['published_at'], 'prerelease': release['prerelease'], 'draft': release['draft'], } ) return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo}) except httpx.RequestError as e: return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}') @self.route( '/github/release-assets', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _() -> str: """Get assets from a specific GitHub release""" data = await quart.request.json owner = data.get('owner', '') repo = data.get('repo', '') release_id = data.get('release_id', '') if not all([owner, repo, release_id]): return self.http_status(400, -1, 'Missing required parameters') try: # Fetch release assets from GitHub API url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}' async with httpx.AsyncClient( trust_env=True, follow_redirects=True, timeout=10, ) as client: response = await client.get( url, ) response.raise_for_status() release = response.json() # Format assets data for frontend formatted_assets = [] for asset in release.get('assets', []): formatted_assets.append( { 'id': asset['id'], 'name': asset['name'], 'size': asset['size'], 'download_url': asset['browser_download_url'], 'content_type': asset['content_type'], } ) # add zipball as a downloadable asset # formatted_assets.append( # { # "id": 0, # "name": "Source code (zip)", # "size": -1, # "download_url": release["zipball_url"], # "content_type": "application/zip", # } # ) return self.success(data={'assets': formatted_assets}) except httpx.RequestError as e: return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}') @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: """Install plugin from GitHub release asset""" limit_error = await self._check_extensions_limit() if limit_error is not None: return limit_error data = await quart.request.json asset_url = data.get('asset_url', '') owner = data.get('owner', '') repo = data.get('repo', '') release_tag = data.get('release_tag', '') if not asset_url: return self.http_status(400, -1, 'Missing asset_url parameter') ctx = taskmgr.TaskContext.new() install_info = { 'asset_url': asset_url, 'owner': owner, 'repo': repo, 'release_tag': release_tag, 'github_url': f'https://github.com/{owner}/{repo}', } wrapper = self.ap.task_mgr.create_user_task( self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx), kind='plugin-operation', name='plugin-install-github', label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}', context=ctx, ) return self.success(data={'task_id': wrapper.id}) @self.route( '/install/marketplace', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _() -> str: limit_error = await self._check_extensions_limit() if limit_error is not None: return limit_error data = await quart.request.json ctx = taskmgr.TaskContext.new() wrapper = self.ap.task_mgr.create_user_task( self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx), kind='plugin-operation', name='plugin-install-marketplace', label=f'Installing plugin from marketplace ...{data}', context=ctx, ) return self.success(data={'task_id': wrapper.id}) @self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: limit_error = await self._check_extensions_limit() if limit_error is not None: return limit_error file = (await quart.request.files).get('file') if file is None: return self.http_status(400, -1, 'file is required') file_bytes = file.read() data = { 'plugin_file': file_bytes, } ctx = taskmgr.TaskContext.new() wrapper = self.ap.task_mgr.create_user_task( self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx), kind='plugin-operation', name='plugin-install-local', label=f'Installing plugin from local ...{file.filename}', context=ctx, ) return self.success(data={'task_id': wrapper.id}) @self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: """Upload a file for plugin configuration""" file = (await quart.request.files).get('file') if file is None: return self.http_status(400, -1, 'file is required') # Check file size (10MB limit) MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB file_bytes = file.read() if len(file_bytes) > MAX_FILE_SIZE: return self.http_status(400, -1, 'file size exceeds 10MB limit') # Generate unique file key with original extension original_filename = file.filename _, ext = os.path.splitext(original_filename) file_key = f'plugin_config_{uuid.uuid4().hex}{ext}' # Save file using storage manager await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes) return self.success(data={'file_key': file_key}) @self.route('/config-files/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN) async def _(file_key: str) -> str: """Delete a plugin configuration file""" # Only allow deletion of files with plugin_config_ prefix for security if not file_key.startswith('plugin_config_'): return self.http_status(400, -1, 'invalid file key') try: await self.ap.storage_mgr.storage_provider.delete(file_key) return self.success(data={'deleted': True}) except Exception as e: return self.http_status(500, -1, f'failed to delete file: {str(e)}') ================================================ FILE: src/langbot/pkg/api/http/controller/groups/provider/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/api/http/controller/groups/provider/models.py ================================================ import quart from ... import group @group.group_class('models/llm', '/api/v1/provider/models/llm') class LLMModelsRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': provider_uuid = quart.request.args.get('provider_uuid') if provider_uuid: return self.success( data={'models': await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)} ) return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()}) elif quart.request.method == 'POST': json_data = await quart.request.json model_uuid = await self.ap.llm_model_service.create_llm_model(json_data) return self.success(data={'uuid': model_uuid}) @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: if quart.request.method == 'GET': model = await self.ap.llm_model_service.get_llm_model(model_uuid) if model is None: return self.http_status(404, -1, 'model not found') return self.success(data={'model': model}) elif quart.request.method == 'PUT': json_data = await quart.request.json await self.ap.llm_model_service.update_llm_model(model_uuid, json_data) return self.success() elif quart.request.method == 'DELETE': await self.ap.llm_model_service.delete_llm_model(model_uuid) return self.success() @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: json_data = await quart.request.json await self.ap.llm_model_service.test_llm_model(model_uuid, json_data) return self.success() @group.group_class('models/embedding', '/api/v1/provider/models/embedding') class EmbeddingModelsRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': provider_uuid = quart.request.args.get('provider_uuid') if provider_uuid: return self.success( data={ 'models': await self.ap.embedding_models_service.get_embedding_models_by_provider( provider_uuid ) } ) return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()}) elif quart.request.method == 'POST': json_data = await quart.request.json model_uuid = await self.ap.embedding_models_service.create_embedding_model(json_data) return self.success(data={'uuid': model_uuid}) @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: if quart.request.method == 'GET': model = await self.ap.embedding_models_service.get_embedding_model(model_uuid) if model is None: return self.http_status(404, -1, 'model not found') return self.success(data={'model': model}) elif quart.request.method == 'PUT': json_data = await quart.request.json await self.ap.embedding_models_service.update_embedding_model(model_uuid, json_data) return self.success() elif quart.request.method == 'DELETE': await self.ap.embedding_models_service.delete_embedding_model(model_uuid) return self.success() @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: json_data = await quart.request.json await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data) return self.success() ================================================ FILE: src/langbot/pkg/api/http/controller/groups/provider/providers.py ================================================ import quart from ... import group @group.group_class('models/providers', '/api/v1/provider/providers') class ModelProvidersRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': providers = await self.ap.provider_service.get_providers() # Add model counts for provider in providers: counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid']) provider['llm_count'] = counts['llm_count'] provider['embedding_count'] = counts['embedding_count'] return self.success(data={'providers': providers}) elif quart.request.method == 'POST': json_data = await quart.request.json provider_uuid = await self.ap.provider_service.create_provider(json_data) return self.success(data={'uuid': provider_uuid}) @self.route( '/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY ) async def _(provider_uuid: str) -> str: if quart.request.method == 'GET': provider = await self.ap.provider_service.get_provider(provider_uuid) if provider is None: return self.http_status(404, -1, 'provider not found') counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid) provider['llm_count'] = counts['llm_count'] provider['embedding_count'] = counts['embedding_count'] return self.success(data={'provider': provider}) elif quart.request.method == 'PUT': json_data = await quart.request.json await self.ap.provider_service.update_provider(provider_uuid, json_data) return self.success() elif quart.request.method == 'DELETE': try: await self.ap.provider_service.delete_provider(provider_uuid) return self.success() except ValueError as e: return self.http_status(400, -1, str(e)) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/provider/requesters.py ================================================ import quart import mimetypes from ... import group from langbot.pkg.utils import importutil @group.group_class('provider/requesters', '/api/v1/provider/requesters') class RequestersRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET']) async def _() -> quart.Response: model_type = quart.request.args.get('type', '') return self.success(data={'requesters': self.ap.model_mgr.get_available_requesters_info(model_type)}) @self.route('/', methods=['GET']) async def _(requester_name: str) -> quart.Response: requester_info = self.ap.model_mgr.get_available_requester_info_by_name(requester_name) if requester_info is None: return self.http_status(404, -1, 'requester not found') return self.success(data={'requester': requester_info}) @self.route('//icon', methods=['GET'], auth_type=group.AuthType.NONE) async def _(requester_name: str) -> quart.Response: requester_manifest = self.ap.model_mgr.get_available_requester_manifest_by_name(requester_name) if requester_manifest is None: return self.http_status(404, -1, 'requester not found') icon_path = requester_manifest.icon_rel_path if icon_path is None: return self.http_status(404, -1, 'icon not found') return quart.Response( importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0] ) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/resources/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/api/http/controller/groups/resources/mcp.py ================================================ from __future__ import annotations import quart import traceback from ... import group @group.group_class('mcp', '/api/v1/mcp') class MCPRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: """获取MCP服务器列表""" if quart.request.method == 'GET': servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) return self.success(data={'servers': servers}) elif quart.request.method == 'POST': data = await quart.request.json try: uuid = await self.ap.mcp_service.create_mcp_server(data) return self.success(data={'uuid': uuid}) except Exception as e: traceback.print_exc() return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}') @self.route('/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: """获取、更新或删除MCP服务器配置""" server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name) if server_data is None: return self.http_status(404, -1, 'Server not found') if quart.request.method == 'GET': return self.success(data={'server': server_data}) elif quart.request.method == 'PUT': data = await quart.request.json try: await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data) return self.success() except Exception as e: return self.http_status(500, -1, f'Failed to update MCP server: {str(e)}') elif quart.request.method == 'DELETE': try: await self.ap.mcp_service.delete_mcp_server(server_data['uuid']) return self.success() except Exception as e: return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}') @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: """测试MCP服务器连接""" server_data = await quart.request.json task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data) return self.success(data={'task_id': task_id}) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/stats.py ================================================ from .. import group @group.group_class('stats', '/api/v1/stats') class StatsRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('/basic', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: conv_count = 0 for session in self.ap.sess_mgr.session_list: conv_count += len(session.conversations if session.conversations is not None else []) return self.success( data={ 'active_session_count': len(self.ap.sess_mgr.session_list), 'conversation_count': conv_count, 'query_count': self.ap.query_pool.query_id_counter, } ) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/survey.py ================================================ import quart from .. import group @group.group_class('survey', '/api/v1/survey') class SurveyRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('/pending', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _get_pending() -> str: """Get pending survey for the frontend to display.""" survey = self.ap.survey.get_pending_survey() if self.ap.survey else None return self.success(data={'survey': survey}) @self.route('/respond', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _respond() -> str: """Submit survey response.""" json_data = await quart.request.json survey_id = json_data.get('survey_id') answers = json_data.get('answers', {}) completed = json_data.get('completed', True) if not survey_id: return self.fail(1, 'survey_id required') if self.ap.survey: ok = await self.ap.survey.submit_response(survey_id, answers, completed) if ok: return self.success() return self.fail(2, 'Failed to submit response') return self.fail(3, 'Survey not available') @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _dismiss() -> str: """Dismiss survey.""" json_data = await quart.request.json survey_id = json_data.get('survey_id') if not survey_id: return self.fail(1, 'survey_id required') if self.ap.survey: ok = await self.ap.survey.dismiss_survey(survey_id) if ok: return self.success() return self.fail(2, 'Failed to dismiss') return self.fail(3, 'Survey not available') ================================================ FILE: src/langbot/pkg/api/http/controller/groups/system.py ================================================ import quart from .. import group from .....utils import constants @group.group_class('system', '/api/v1/system') class SystemRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE) async def _() -> str: return self.success( data={ 'version': constants.semantic_version, 'debug': constants.debug_mode, 'edition': constants.edition, 'enable_marketplace': self.ap.instance_config.data.get('plugin', {}).get( 'enable_marketplace', True ), 'cloud_service_url': ( self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app') ), 'allow_modify_login_info': self.ap.instance_config.data.get('system', {}).get( 'allow_modify_login_info', True ), 'disable_models_service': self.ap.instance_config.data.get('space', {}).get( 'disable_models_service', False ), 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), } ) @self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: task_type = quart.request.args.get('type') if task_type == '': task_type = None return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type)) @self.route('/tasks/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _(task_id: str) -> str: task = self.ap.task_mgr.get_task_by_id(int(task_id)) if task is None: return self.http_status(404, 404, 'Task not found') return self.success(data=task.to_dict()) @self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: if not constants.debug_mode: return self.http_status(403, 403, 'Forbidden') py_code = await quart.request.data ap = self.ap return self.success(data=exec(py_code, {'ap': ap})) @self.route( '/debug/plugin/action', methods=['POST'], auth_type=group.AuthType.USER_TOKEN, ) async def _() -> str: if not constants.debug_mode: return self.http_status(403, 403, 'Forbidden') data = await quart.request.json class AnoymousAction: value = 'anonymous_action' def __init__(self, value: str): self.value = value resp = await self.ap.plugin_connector.handler.call_action( AnoymousAction(data['action']), data['data'], timeout=data.get('timeout', 10), ) return self.success(data=resp) @self.route( '/status/plugin-system', methods=['GET'], auth_type=group.AuthType.USER_TOKEN, ) async def _() -> str: plugin_connector_error = 'ok' is_connected = True try: await self.ap.plugin_connector.ping_plugin_runtime() except Exception as e: plugin_connector_error = str(e) is_connected = False return self.success( data={ 'is_enable': self.ap.plugin_connector.is_enable_plugin, 'is_connected': is_connected, 'plugin_connector_error': plugin_connector_error, } ) ================================================ FILE: src/langbot/pkg/api/http/controller/groups/user.py ================================================ import quart import argon2 import asyncio import traceback from .. import group from .....entity.errors import account as account_errors @group.group_class('user', '/api/v1/user') class UserRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) async def _() -> str: if quart.request.method == 'GET': return self.success(data={'initialized': await self.ap.user_service.is_initialized()}) if await self.ap.user_service.is_initialized(): return self.fail(1, 'System already initialized') json_data = await quart.request.json user_email = json_data['user'] password = json_data['password'] await self.ap.user_service.create_user(user_email, password) return self.success() @self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE) async def _() -> str: json_data = await quart.request.json try: token = await self.ap.user_service.authenticate(json_data['user'], json_data['password']) except argon2.exceptions.VerifyMismatchError: return self.fail(1, 'Invalid username or password') except ValueError as e: return self.fail(1, str(e)) return self.success(data={'token': token}) @self.route('/check-token', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _(user_email: str) -> str: token = await self.ap.user_service.generate_jwt_token(user_email) return self.success(data={'token': token}) @self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE) async def _() -> str: json_data = await quart.request.json user_email = json_data['user'] recovery_key = json_data['recovery_key'] new_password = json_data['new_password'] # hard sleep 3s for security await asyncio.sleep(3) if not await self.ap.user_service.is_initialized(): return self.http_status(400, -1, 'System not initialized') user_obj = await self.ap.user_service.get_user_by_email(user_email) if user_obj is None: return self.http_status(400, -1, 'User not found') if recovery_key != self.ap.instance_config.data['system']['recovery_key']: return self.http_status(403, -1, 'Invalid recovery key') await self.ap.user_service.reset_password(user_email, new_password) return self.success(data={'user': user_email}) @self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(user_email: str) -> str: # Check if password change is allowed allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( 'allow_modify_login_info', True ) if not allow_modify_login_info: return self.http_status(403, -1, 'Modifying login info is disabled') json_data = await quart.request.json current_password = json_data['current_password'] new_password = json_data['new_password'] try: await self.ap.user_service.change_password(user_email, current_password, new_password) except argon2.exceptions.VerifyMismatchError: return self.http_status(400, -1, 'Current password is incorrect') except ValueError as e: return self.http_status(400, -1, str(e)) return self.success(data={'user': user_email}) # Space OAuth endpoints (redirect flow) @self.route('/space/authorize-url', methods=['GET'], auth_type=group.AuthType.NONE) async def _() -> str: """Get Space OAuth authorization URL for redirect""" redirect_uri = quart.request.args.get('redirect_uri', '') state = quart.request.args.get('state', '') if not redirect_uri: return self.fail(1, 'Missing redirect_uri parameter') try: authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state) return self.success(data={'authorize_url': authorize_url}) except Exception as e: return self.fail(1, str(e)) @self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE) async def _() -> str: """Handle OAuth callback - exchange code for tokens and authenticate""" json_data = await quart.request.json code = json_data.get('code') if not code: return self.fail(1, 'Missing authorization code') try: # Exchange code for tokens token_data = await self.ap.space_service.exchange_oauth_code(code) access_token = token_data.get('access_token') refresh_token = token_data.get('refresh_token') expires_in = token_data.get('expires_in', 0) if not access_token: return self.fail(1, 'Failed to get access token from Space') # Authenticate and create/update local user jwt_token, user_obj = await self.ap.user_service.authenticate_space_user( access_token, refresh_token, expires_in ) return self.success( data={ 'token': jwt_token, 'user': user_obj.user, } ) except account_errors.AccountEmailMismatchError as e: return self.fail(3, str(e)) except ValueError as e: traceback.print_exc() return self.fail(1, str(e)) except Exception as e: traceback.print_exc() return self.fail(2, f'OAuth callback failed: {str(e)}') @self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _(user_email: str) -> str: """Get current user information including account type""" user_obj = await self.ap.user_service.get_user_by_email(user_email) if user_obj is None: return self.http_status(404, -1, 'User not found') return self.success( data={ 'user': user_obj.user, 'account_type': user_obj.account_type, 'has_password': bool(user_obj.password and user_obj.password.strip()), } ) @self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _(user_email: str) -> str: """Get Space credits balance for current user""" credits = await self.ap.space_service.get_credits(user_email) return self.success(data={'credits': credits}) @self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE) async def _() -> str: """Get account info for login page (account type and has_password)""" if not await self.ap.user_service.is_initialized(): return self.success(data={'initialized': False}) user_obj = await self.ap.user_service.get_first_user() if user_obj is None: return self.success(data={'initialized': False}) return self.success( data={ 'initialized': True, 'account_type': user_obj.account_type, 'has_password': bool(user_obj.password and user_obj.password.strip()), } ) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(user_email: str) -> str: """Set password for Space account (first time) or change password""" json_data = await quart.request.json new_password = json_data.get('new_password') current_password = json_data.get('current_password') if not new_password: return self.http_status(400, -1, 'New password is required') user_obj = await self.ap.user_service.get_user_by_email(user_email) if user_obj is None: return self.http_status(404, -1, 'User not found') try: await self.ap.user_service.set_password(user_email, new_password, current_password) return self.success(data={'user': user_email}) except ValueError as e: return self.http_status(400, -1, str(e)) except argon2.exceptions.VerifyMismatchError: return self.http_status(400, -1, 'Current password is incorrect') @self.route('/bind-space', methods=['POST'], auth_type=group.AuthType.NONE) async def _() -> str: """Bind Space account to existing local account""" # Check if modifying login info is allowed allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( 'allow_modify_login_info', True ) if not allow_modify_login_info: return self.http_status(403, -1, 'Modifying login info is disabled') json_data = await quart.request.json code = json_data.get('code') state = json_data.get('state') # JWT token passed as state if not code: return self.http_status(400, -1, 'Missing authorization code') if not state: return self.http_status(400, -1, 'Missing state parameter') # Verify state is a valid JWT token try: user_email = await self.ap.user_service.verify_jwt_token(state) except Exception: return self.http_status(401, -1, 'Invalid or expired state') user_obj = await self.ap.user_service.get_user_by_email(user_email) if user_obj is None: return self.http_status(404, -1, 'User not found') if user_obj.account_type != 'local': return self.http_status(400, -1, 'Only local accounts can bind to Space') try: updated_user = await self.ap.user_service.bind_space_account(user_email, code) jwt_token = await self.ap.user_service.generate_jwt_token(updated_user.user) return self.success( data={ 'token': jwt_token, 'user': updated_user.user, 'account_type': updated_user.account_type, } ) except ValueError as e: return self.http_status(400, -1, str(e)) except Exception as e: return self.http_status(500, -1, f'Failed to bind Space account: {str(e)}') ================================================ FILE: src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py ================================================ import quart from .. import group @group.group_class('webhook_mgmt', '/api/v1/webhooks') class WebhookManagementRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET', 'POST']) async def _() -> str: if quart.request.method == 'GET': webhooks = await self.ap.webhook_service.get_webhooks() return self.success(data={'webhooks': webhooks}) elif quart.request.method == 'POST': json_data = await quart.request.json name = json_data.get('name', '') url = json_data.get('url', '') description = json_data.get('description', '') enabled = json_data.get('enabled', True) if not name: return self.http_status(400, -1, 'Name is required') if not url: return self.http_status(400, -1, 'URL is required') webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled) return self.success(data={'webhook': webhook}) @self.route('/', methods=['GET', 'PUT', 'DELETE']) async def _(webhook_id: int) -> str: if quart.request.method == 'GET': webhook = await self.ap.webhook_service.get_webhook(webhook_id) if webhook is None: return self.http_status(404, -1, 'Webhook not found') return self.success(data={'webhook': webhook}) elif quart.request.method == 'PUT': json_data = await quart.request.json name = json_data.get('name') url = json_data.get('url') description = json_data.get('description') enabled = json_data.get('enabled') await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled) return self.success() elif quart.request.method == 'DELETE': await self.ap.webhook_service.delete_webhook(webhook_id) return self.success() ================================================ FILE: src/langbot/pkg/api/http/controller/groups/webhooks.py ================================================ from __future__ import annotations import quart import traceback from .. import group @group.group_class('webhooks', '/bots') class WebhookRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('/', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) async def handle_webhook(bot_uuid: str): """处理 bot webhook 回调(无子路径)""" return await self._dispatch_webhook(bot_uuid, '') @self.route('//', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) async def handle_webhook_with_path(bot_uuid: str, path: str): """处理 bot webhook 回调(带子路径)""" return await self._dispatch_webhook(bot_uuid, path) async def _dispatch_webhook(self, bot_uuid: str, path: str): """分发 webhook 请求到对应的 bot adapter Args: bot_uuid: Bot 的 UUID path: 子路径(如果有的话) Returns: 适配器返回的响应 """ try: runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) if not runtime_bot: return quart.jsonify({'error': 'Bot not found'}), 404 if not runtime_bot.enable: return quart.jsonify({'error': 'Bot is disabled'}), 403 if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'): return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501 response = await runtime_bot.adapter.handle_unified_webhook( bot_uuid=bot_uuid, path=path, request=quart.request, ) return response except Exception as e: self.ap.logger.error(f'Webhook dispatch error for bot {bot_uuid}: {traceback.format_exc()}') return quart.jsonify({'error': str(e)}), 500 ================================================ FILE: src/langbot/pkg/api/http/controller/main.py ================================================ from __future__ import annotations import asyncio import os import quart import quart_cors from werkzeug.exceptions import RequestEntityTooLarge from ....core import app, entities as core_entities from ....utils import importutil from . import groups from . import group from .groups import provider as groups_provider from .groups import platform as groups_platform from .groups import pipelines as groups_pipelines from .groups import knowledge as groups_knowledge from .groups import resources as groups_resources importutil.import_modules_in_pkg(groups) importutil.import_modules_in_pkg(groups_provider) importutil.import_modules_in_pkg(groups_platform) importutil.import_modules_in_pkg(groups_pipelines) importutil.import_modules_in_pkg(groups_knowledge) importutil.import_modules_in_pkg(groups_resources) class HTTPController: ap: app.Application quart_app: quart.Quart def __init__(self, ap: app.Application) -> None: self.ap = ap self.quart_app = quart.Quart(__name__) quart_cors.cors(self.quart_app, allow_origin='*') # Set maximum content length to prevent large file uploads self.quart_app.config['MAX_CONTENT_LENGTH'] = group.MAX_FILE_SIZE async def initialize(self) -> None: # Register custom error handler for file size limit @self.quart_app.errorhandler(RequestEntityTooLarge) async def handle_request_entity_too_large(e): return quart.jsonify( { 'code': 400, 'msg': 'File size exceeds 10MB limit. Please split large files into smaller parts.', } ), 400 await self.register_routes() async def run(self) -> None: if True: async def shutdown_trigger_placeholder(): while True: await asyncio.sleep(1) async def exception_handler(*args, **kwargs): try: await self.quart_app.run_task(*args, **kwargs) except Exception as e: self.ap.logger.error(f'Failed to start HTTP service: {e}') self.ap.task_mgr.create_task( exception_handler( host='0.0.0.0', port=self.ap.instance_config.data['api']['port'], shutdown_trigger=shutdown_trigger_placeholder, ), name='http-api-quart', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) # await asyncio.sleep(5) async def register_routes(self) -> None: @self.quart_app.route('/healthz') async def healthz(): return {'code': 0, 'msg': 'ok'} for g in group.preregistered_groups: ginst = g(self.ap, self.quart_app) await ginst.initialize() from ....utils import paths frontend_path = paths.get_frontend_path() @self.quart_app.route('/') async def index(): response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html') response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '0' return response @self.quart_app.route('/') async def static_file(path: str): if not ( os.path.exists(os.path.join(frontend_path, path)) and os.path.isfile(os.path.join(frontend_path, path)) ): if os.path.exists(os.path.join(frontend_path, path + '.html')): path += '.html' else: return await quart.send_from_directory(frontend_path, '404.html') mimetype = None if path.endswith('.html'): mimetype = 'text/html' elif path.endswith('.js'): mimetype = 'application/javascript' elif path.endswith('.css'): mimetype = 'text/css' elif path.endswith('.png'): mimetype = 'image/png' elif path.endswith('.jpg'): mimetype = 'image/jpeg' elif path.endswith('.jpeg'): mimetype = 'image/jpeg' elif path.endswith('.gif'): mimetype = 'image/gif' elif path.endswith('.svg'): mimetype = 'image/svg+xml' elif path.endswith('.ico'): mimetype = 'image/x-icon' elif path.endswith('.json'): mimetype = 'application/json' elif path.endswith('.txt'): mimetype = 'text/plain' response = await quart.send_from_directory(frontend_path, path, mimetype=mimetype) response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '0' return response ================================================ FILE: src/langbot/pkg/api/http/service/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/api/http/service/apikey.py ================================================ from __future__ import annotations import secrets import sqlalchemy from ....core import app from ....entity.persistence import apikey class ApiKeyService: ap: app.Application def __init__(self, ap: app.Application) -> None: self.ap = ap async def get_api_keys(self) -> list[dict]: """Get all API keys""" result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(apikey.ApiKey)) keys = result.all() return [self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) for key in keys] async def create_api_key(self, name: str, description: str = '') -> dict: """Create a new API key""" # Generate a secure random API key key = f'lbk_{secrets.token_urlsafe(32)}' key_data = {'name': name, 'key': key, 'description': description} await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(apikey.ApiKey).values(**key_data)) # Retrieve the created key result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key) ) created_key = result.first() return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, created_key) async def get_api_key(self, key_id: int) -> dict | None: """Get a specific API key by ID""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id) ) key = result.first() if key is None: return None return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) async def verify_api_key(self, key: str) -> bool: """Verify if an API key is valid""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key) ) key_obj = result.first() return key_obj is not None async def delete_api_key(self, key_id: int) -> None: """Delete an API key""" await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id)) async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None: """Update an API key's metadata (name, description)""" update_data = {} if name is not None: update_data['name'] = name if description is not None: update_data['description'] = description if update_data: await self.ap.persistence_mgr.execute_async( sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data) ) ================================================ FILE: src/langbot/pkg/api/http/service/bot.py ================================================ from __future__ import annotations import uuid import sqlalchemy import typing from ....core import app from ....entity.persistence import bot as persistence_bot from ....entity.persistence import pipeline as persistence_pipeline class BotService: """Bot service""" ap: app.Application def __init__(self, ap: app.Application) -> None: self.ap = ap async def get_bots(self, include_secret: bool = True) -> list[dict]: """获取所有机器人""" result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot)) bots = result.all() masked_columns = [] if not include_secret: masked_columns = ['adapter_config'] return [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) for bot in bots] async def get_bot(self, bot_uuid: str, include_secret: bool = True) -> dict | None: """获取机器人""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid) ) bot = result.first() if bot is None: return None masked_columns = [] if not include_secret: masked_columns = ['adapter_config'] return self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) async def get_runtime_bot_info(self, bot_uuid: str, include_secret: bool = True) -> dict: """获取机器人运行时信息""" persistence_bot = await self.get_bot(bot_uuid, include_secret) if persistence_bot is None: raise Exception('Bot not found') adapter_runtime_values = {} runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) if runtime_bot is not None: adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id # Webhook URL for unified webhook adapters (independent of bot running state) if persistence_bot['adapter'] in [ 'wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE', 'lark', ]: webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300') extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '') webhook_url = f'/bots/{bot_uuid}' adapter_runtime_values['webhook_url'] = webhook_url adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}' adapter_runtime_values['extra_webhook_full_url'] = ( f'{extra_webhook_prefix}{webhook_url}' if extra_webhook_prefix else '' ) else: adapter_runtime_values['webhook_url'] = None adapter_runtime_values['webhook_full_url'] = None adapter_runtime_values['extra_webhook_full_url'] = None persistence_bot['adapter_runtime_values'] = adapter_runtime_values return persistence_bot async def create_bot(self, bot_data: dict) -> str: """Create bot""" # Check limitation limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) max_bots = limitation.get('max_bots', -1) if max_bots >= 0: existing_bots = await self.get_bots() if len(existing_bots) >= max_bots: raise ValueError(f'Maximum number of bots ({max_bots}) reached') # TODO: 检查配置信息格式 bot_data['uuid'] = str(uuid.uuid4()) # checkout the default pipeline result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( persistence_pipeline.LegacyPipeline.is_default == True ) ) pipeline = result.first() if pipeline is not None: bot_data['use_pipeline_uuid'] = pipeline.uuid bot_data['use_pipeline_name'] = pipeline.name await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data)) bot = await self.get_bot(bot_data['uuid']) await self.ap.platform_mgr.load_bot(bot) return bot_data['uuid'] async def update_bot(self, bot_uuid: str, bot_data: dict) -> None: """Update bot""" if 'uuid' in bot_data: del bot_data['uuid'] # set use_pipeline_name if 'use_pipeline_uuid' in bot_data: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( persistence_pipeline.LegacyPipeline.uuid == bot_data['use_pipeline_uuid'] ) ) pipeline = result.first() if pipeline is not None: bot_data['use_pipeline_name'] = pipeline.name else: raise Exception('Pipeline not found') await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_bot.Bot).values(bot_data).where(persistence_bot.Bot.uuid == bot_uuid) ) await self.ap.platform_mgr.remove_bot(bot_uuid) # select from db bot = await self.get_bot(bot_uuid) runtime_bot = await self.ap.platform_mgr.load_bot(bot) if runtime_bot.enable: await runtime_bot.run() # update all conversation that use this bot for session in self.ap.sess_mgr.session_list: if session.using_conversation is not None and session.using_conversation.bot_uuid == bot_uuid: session.using_conversation = None async def delete_bot(self, bot_uuid: str) -> None: """Delete bot""" await self.ap.platform_mgr.remove_bot(bot_uuid) await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid) ) async def list_event_logs( self, bot_uuid: str, from_index: int, max_count: int ) -> typing.Tuple[list[dict], int, int, int]: runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) if runtime_bot is None: raise Exception('Bot not found') logs, total_count = await runtime_bot.logger.get_logs(from_index, max_count) return [log.to_json() for log in logs], total_count async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None: """Send message to a specific target via bot Args: bot_uuid: The UUID of the bot target_type: The type of the target, can be "group", "person" target_id: The ID of the target message_chain_data: The message chain data in dict format """ # Import here to avoid circular imports import langbot_plugin.api.entities.builtin.platform.message as platform_message # Get runtime bot runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) if runtime_bot is None: raise Exception(f'Bot not found: {bot_uuid}') # Validate and convert message chain try: message_chain = platform_message.MessageChain.model_validate(message_chain_data) except Exception as e: raise Exception(f'Invalid message_chain format: {str(e)}') # Send message via adapter await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain) ================================================ FILE: src/langbot/pkg/api/http/service/knowledge.py ================================================ from __future__ import annotations import sqlalchemy from ....core import app from ....entity.persistence import rag as persistence_rag class KnowledgeService: """知识库服务""" ap: app.Application def __init__(self, ap: app.Application) -> None: self.ap = ap async def get_knowledge_bases(self) -> list[dict]: """获取所有知识库""" return await self.ap.rag_mgr.get_all_knowledge_base_details() async def get_knowledge_base(self, kb_uuid: str) -> dict | None: """获取知识库""" return await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid) async def create_knowledge_base(self, kb_data: dict) -> str: """创建知识库""" # In new architecture, we delegate entirely to RAGManager which uses plugins. # Legacy internal KB creation is removed. knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id') if not knowledge_engine_plugin_id: raise ValueError('knowledge_engine_plugin_id is required') kb = await self.ap.rag_mgr.create_knowledge_base( name=kb_data.get('name', 'Untitled'), knowledge_engine_plugin_id=knowledge_engine_plugin_id, creation_settings=kb_data.get('creation_settings', {}), retrieval_settings=kb_data.get('retrieval_settings', {}), description=kb_data.get('description', ''), ) return kb.uuid async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None: """更新知识库""" # Filter to only mutable fields filtered_data = {k: v for k, v in kb_data.items() if k in persistence_rag.KnowledgeBase.MUTABLE_FIELDS} if not filtered_data: return await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_rag.KnowledgeBase) .values(filtered_data) .where(persistence_rag.KnowledgeBase.uuid == kb_uuid) ) await self.ap.rag_mgr.remove_knowledge_base_from_runtime(kb_uuid) kb = await self.get_knowledge_base(kb_uuid) if kb is None: raise Exception('Knowledge base not found after update') await self.ap.rag_mgr.load_knowledge_base(kb) async def _check_doc_capability(self, kb_uuid: str, operation: str) -> None: """Check if the KB's Knowledge Engine supports document operations. Args: kb_uuid: Knowledge base UUID. operation: Human-readable operation name for error messages. Raises: Exception: If the KB does not support doc_ingestion. """ kb_info = await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid) if not kb_info: raise Exception('Knowledge base not found') capabilities = kb_info.get('knowledge_engine', {}).get('capabilities', []) if 'doc_ingestion' not in capabilities: raise Exception(f'This knowledge base does not support {operation}') async def store_file(self, kb_uuid: str, file_id: str, parser_plugin_id: str | None = None) -> str: """存储文件""" runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) if runtime_kb is None: raise Exception('Knowledge base not found') await self._check_doc_capability(kb_uuid, 'document upload') result = await runtime_kb.store_file(file_id, parser_plugin_id=parser_plugin_id) # Update the KB's updated_at timestamp await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_rag.KnowledgeBase) .values(updated_at=sqlalchemy.func.now()) .where(persistence_rag.KnowledgeBase.uuid == kb_uuid) ) return result async def retrieve_knowledge_base( self, kb_uuid: str, query: str, retrieval_settings: dict | None = None ) -> list[dict]: """检索知识库""" runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) if runtime_kb is None: raise Exception('Knowledge base not found') # Pass retrieval_settings results = await runtime_kb.retrieve(query, settings=retrieval_settings) return [result.model_dump() for result in results] async def get_files_by_knowledge_base(self, kb_uuid: str) -> list[dict]: """获取知识库文件""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid) ) files = result.all() return [self.ap.persistence_mgr.serialize_model(persistence_rag.File, file) for file in files] async def delete_file(self, kb_uuid: str, file_id: str) -> None: """删除文件""" runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) if runtime_kb is None: raise Exception('Knowledge base not found') await self._check_doc_capability(kb_uuid, 'document deletion') await runtime_kb.delete_file(file_id) # Update the KB's updated_at timestamp await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_rag.KnowledgeBase) .values(updated_at=sqlalchemy.func.now()) .where(persistence_rag.KnowledgeBase.uuid == kb_uuid) ) async def delete_knowledge_base(self, kb_uuid: str) -> None: """删除知识库""" # Delete from DB first to commit the deletion, then clean up runtime/plugin (best-effort) await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid) ) # delete files # NOTE: Chunk cleanup is for legacy (pre-plugin) KBs that stored chunks locally. # For plugin-based Knowledge Engines, the Chunk table is not populated, so this is a no-op. files = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid) ) for file in files: # delete chunks await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_rag.Chunk).where(persistence_rag.Chunk.file_id == file.uuid) ) # delete file await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file.uuid) ) # Remove from runtime and notify plugin (best-effort, DB is already cleaned up) await self.ap.rag_mgr.delete_knowledge_base(kb_uuid) # ================= Knowledge Engine Discovery ================= async def list_knowledge_engines(self) -> list[dict]: """List all available Knowledge Engines from plugins.""" engines = [] if not self.ap.plugin_connector.is_enable_plugin: return engines # Get KnowledgeEngine plugins try: knowledge_engines = await self.ap.plugin_connector.list_knowledge_engines() engines.extend(knowledge_engines) except Exception as e: self.ap.logger.warning(f'Failed to list Knowledge Engines from plugins: {e}') return engines async def list_parsers(self, mime_type: str | None = None) -> list[dict]: """List available parsers, optionally filtered by MIME type.""" if not self.ap.plugin_connector.is_enable_plugin: return [] try: parsers = await self.ap.plugin_connector.list_parsers() if mime_type: parsers = [p for p in parsers if mime_type in p.get('supported_mime_types', [])] return parsers except Exception as e: self.ap.logger.warning(f'Failed to list parsers: {e}') return [] async def get_engine_creation_schema(self, plugin_id: str) -> dict: """Get creation settings schema for a specific Knowledge Engine.""" try: return await self.ap.plugin_connector.get_rag_creation_schema(plugin_id) except Exception as e: self.ap.logger.warning(f'Failed to get creation schema for {plugin_id}: {e}') return {} async def get_engine_retrieval_schema(self, plugin_id: str) -> dict: """Get retrieval settings schema for a specific Knowledge Engine.""" try: return await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id) except Exception as e: self.ap.logger.warning(f'Failed to get retrieval schema for {plugin_id}: {e}') return {} ================================================ FILE: src/langbot/pkg/api/http/service/mcp.py ================================================ from __future__ import annotations import sqlalchemy import uuid import asyncio from ....core import app from ....entity.persistence import mcp as persistence_mcp from ....core import taskmgr from ....provider.tools.loaders.mcp import RuntimeMCPSession, MCPSessionStatus class MCPService: ap: app.Application def __init__(self, ap: app.Application) -> None: self.ap = ap async def get_runtime_info(self, server_name: str) -> dict | None: session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) if session: return session.get_runtime_info_dict() return None async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict]: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) servers = result.all() serialized_servers = [ self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers ] if contain_runtime_info: for server in serialized_servers: runtime_info = await self.get_runtime_info(server['name']) server['runtime_info'] = runtime_info if runtime_info else None return serialized_servers async def create_mcp_server(self, server_data: dict) -> str: # Check limitation (extensions = MCP servers + plugins) limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) max_extensions = limitation.get('max_extensions', -1) if max_extensions >= 0: existing_mcp_servers = await self.get_mcp_servers() plugins = await self.ap.plugin_connector.list_plugins() total_extensions = len(existing_mcp_servers) + len(plugins) if total_extensions >= max_extensions: raise ValueError(f'Maximum number of extensions ({max_extensions}) reached') server_data['uuid'] = str(uuid.uuid4()) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid']) ) server_entity = result.first() if server_entity: server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity) if self.ap.tool_mgr.mcp_tool_loader: task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) return server_data['uuid'] async def get_mcp_server_by_name(self, server_name: str) -> dict | None: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name) ) server = result.first() if server is None: return None runtime_info = await self.get_runtime_info(server.name) server_data = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) server_data['runtime_info'] = runtime_info if runtime_info else None return server_data async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) ) old_server = result.first() old_server_name = old_server.name if old_server else None old_enable = old_server.enable if old_server else False await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_mcp.MCPServer) .where(persistence_mcp.MCPServer.uuid == server_uuid) .values(server_data) ) if self.ap.tool_mgr.mcp_tool_loader: new_enable = server_data.get('enable', False) need_remove = old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions if old_enable and not new_enable: if need_remove: await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name) elif not old_enable and new_enable: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) ) updated_server = result.first() if updated_server: server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) elif old_enable and new_enable: if need_remove: await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name) result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) ) updated_server = result.first() if updated_server: server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) async def delete_mcp_server(self, server_uuid: str) -> None: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) ) server = result.first() server_name = server.name if server else None await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) ) if server_name and self.ap.tool_mgr.mcp_tool_loader: if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions: await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name) async def test_mcp_server(self, server_name: str, server_data: dict) -> int: """测试 MCP 服务器连接并返回任务 ID""" runtime_mcp_session: RuntimeMCPSession | None = None if server_name != '_': runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) if runtime_mcp_session is None: raise ValueError(f'Server not found: {server_name}') if runtime_mcp_session.status == MCPSessionStatus.ERROR: coroutine = runtime_mcp_session.start() else: coroutine = runtime_mcp_session.refresh() else: runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data) coroutine = runtime_mcp_session.start() ctx = taskmgr.TaskContext.new() wrapper = self.ap.task_mgr.create_user_task( coroutine, kind='mcp-operation', name=f'mcp-test-{server_name}', label=f'Testing MCP server {server_name}', context=ctx, ) return wrapper.id ================================================ FILE: src/langbot/pkg/api/http/service/model.py ================================================ from __future__ import annotations import uuid import sqlalchemy from langbot_plugin.api.entities.builtin.provider import message as provider_message from ....core import app from ....entity.persistence import model as persistence_model from ....entity.persistence import pipeline as persistence_pipeline from ....provider.modelmgr import requester as model_requester def _parse_provider_api_keys(provider_dict: dict) -> dict: """Parse api_keys if it's a JSON string""" if isinstance(provider_dict.get('api_keys'), str): import json try: provider_dict['api_keys'] = json.loads(provider_dict['api_keys']) except Exception: provider_dict['api_keys'] = [] return provider_dict class LLMModelsService: ap: app.Application def __init__(self, ap: app.Application) -> None: self.ap = ap async def get_llm_models(self, include_secret: bool = True) -> list[dict]: """Get all LLM models with provider info""" result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel)) models = result.all() # Get all providers for lookup providers_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.ModelProvider) ) providers = {p.uuid: p for p in providers_result.all()} models_list = [] for model in models: model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model) provider = providers.get(model.provider_uuid) if provider: provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) provider_dict = _parse_provider_api_keys(provider_dict) if not include_secret: provider_dict['api_keys'] = ['***'] * len(provider_dict.get('api_keys', [])) model_dict['provider'] = provider_dict models_list.append(model_dict) return models_list async def get_llm_models_by_provider(self, provider_uuid: str) -> list[dict]: """Get LLM models by provider UUID""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.LLMModel).where( persistence_model.LLMModel.provider_uuid == provider_uuid ) ) models = result.all() return [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models] async def create_llm_model( self, model_data: dict, preserve_uuid: bool = False, auto_set_to_default_pipeline: bool = True ) -> str: """Create a new LLM model""" if not preserve_uuid: model_data['uuid'] = str(uuid.uuid4()) # Handle provider creation if needed if 'provider' in model_data: provider_data = model_data.pop('provider') if provider_data.get('uuid'): model_data['provider_uuid'] = provider_data['uuid'] else: # Create new provider provider_uuid = await self.ap.provider_service.find_or_create_provider( requester=provider_data.get('requester', ''), base_url=provider_data.get('base_url', ''), api_keys=provider_data.get('api_keys', []), ) model_data['provider_uuid'] = provider_uuid await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data)) runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) if runtime_provider is None: raise Exception('provider not found') runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider( persistence_model.LLMModel(**model_data), runtime_provider, ) self.ap.model_mgr.llm_models.append(runtime_llm_model) if auto_set_to_default_pipeline: # set the default pipeline model to this model result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( persistence_pipeline.LegacyPipeline.is_default == True ) ) pipeline = result.first() if pipeline is not None: model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {}) if not model_config.get('primary', ''): pipeline_config = pipeline.config pipeline_config['ai']['local-agent']['model'] = { 'primary': model_data['uuid'], 'fallbacks': [], } pipeline_data = {'config': pipeline_config} await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data) return model_data['uuid'] async def get_llm_model(self, model_uuid: str) -> dict | None: """Get a single LLM model with provider info""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid) ) model = result.first() if model is None: return None model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model) # Get provider provider_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.ModelProvider).where( persistence_model.ModelProvider.uuid == model.provider_uuid ) ) provider = provider_result.first() if provider: provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) model_dict['provider'] = _parse_provider_api_keys(provider_dict) return model_dict async def update_llm_model(self, model_uuid: str, model_data: dict) -> None: """Update an existing LLM model""" if 'uuid' in model_data: del model_data['uuid'] # Handle provider update if needed if 'provider' in model_data: provider_data = model_data.pop('provider') if provider_data.get('uuid'): model_data['provider_uuid'] = provider_data['uuid'] else: provider_uuid = await self.ap.provider_service.find_or_create_provider( requester=provider_data.get('requester', ''), base_url=provider_data.get('base_url', ''), api_keys=provider_data.get('api_keys', []), ) model_data['provider_uuid'] = provider_uuid await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_model.LLMModel) .where(persistence_model.LLMModel.uuid == model_uuid) .values(**model_data) ) await self.ap.model_mgr.remove_llm_model(model_uuid) runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) if runtime_provider is None: raise Exception('provider not found') runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider( persistence_model.LLMModel(**model_data), runtime_provider, ) self.ap.model_mgr.llm_models.append(runtime_llm_model) async def delete_llm_model(self, model_uuid: str) -> None: """Delete an LLM model""" await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid) ) await self.ap.model_mgr.remove_llm_model(model_uuid) async def test_llm_model(self, model_uuid: str, model_data: dict) -> None: """Test an LLM model""" runtime_llm_model: model_requester.RuntimeLLMModel | None = None if model_uuid != '_': for model in self.ap.model_mgr.llm_models: if model.model_entity.uuid == model_uuid: runtime_llm_model = model break if runtime_llm_model is None: raise Exception('model not found') else: runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(model_data) extra_args = model_data.get('extra_args', {}) await runtime_llm_model.provider.invoke_llm( query=None, model=runtime_llm_model, messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')], funcs=[], extra_args=extra_args, ) class EmbeddingModelsService: ap: app.Application def __init__(self, ap: app.Application) -> None: self.ap = ap async def get_embedding_models(self) -> list[dict]: """Get all embedding models with provider info""" result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel)) models = result.all() providers_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.ModelProvider) ) providers = {p.uuid: p for p in providers_result.all()} models_list = [] for model in models: model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model) provider = providers.get(model.provider_uuid) if provider: provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) model_dict['provider'] = _parse_provider_api_keys(provider_dict) models_list.append(model_dict) return models_list async def get_embedding_models_by_provider(self, provider_uuid: str) -> list[dict]: """Get embedding models by provider UUID""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.EmbeddingModel).where( persistence_model.EmbeddingModel.provider_uuid == provider_uuid ) ) models = result.all() return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models] async def create_embedding_model(self, model_data: dict, preserve_uuid: bool = False) -> str: """Create a new embedding model""" if not preserve_uuid: model_data['uuid'] = str(uuid.uuid4()) if 'provider' in model_data: provider_data = model_data.pop('provider') if provider_data.get('uuid'): model_data['provider_uuid'] = provider_data['uuid'] else: provider_uuid = await self.ap.provider_service.find_or_create_provider( requester=provider_data.get('requester', ''), base_url=provider_data.get('base_url', ''), api_keys=provider_data.get('api_keys', []), ) model_data['provider_uuid'] = provider_uuid await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data) ) runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) if runtime_provider is None: raise Exception('provider not found') runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider( persistence_model.EmbeddingModel(**model_data), runtime_provider, ) self.ap.model_mgr.embedding_models.append(runtime_embedding_model) return model_data['uuid'] async def get_embedding_model(self, model_uuid: str) -> dict | None: """Get a single embedding model with provider info""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.EmbeddingModel).where( persistence_model.EmbeddingModel.uuid == model_uuid ) ) model = result.first() if model is None: return None model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model) provider_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.ModelProvider).where( persistence_model.ModelProvider.uuid == model.provider_uuid ) ) provider = provider_result.first() if provider: provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) model_dict['provider'] = _parse_provider_api_keys(provider_dict) return model_dict async def update_embedding_model(self, model_uuid: str, model_data: dict) -> None: """Update an existing embedding model""" if 'uuid' in model_data: del model_data['uuid'] if 'provider' in model_data: provider_data = model_data.pop('provider') if provider_data.get('uuid'): model_data['provider_uuid'] = provider_data['uuid'] else: provider_uuid = await self.ap.provider_service.find_or_create_provider( requester=provider_data.get('requester', ''), base_url=provider_data.get('base_url', ''), api_keys=provider_data.get('api_keys', []), ) model_data['provider_uuid'] = provider_uuid await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_model.EmbeddingModel) .where(persistence_model.EmbeddingModel.uuid == model_uuid) .values(**model_data) ) await self.ap.model_mgr.remove_embedding_model(model_uuid) runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) if runtime_provider is None: raise Exception('provider not found') runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider( persistence_model.EmbeddingModel(**model_data), runtime_provider, ) self.ap.model_mgr.embedding_models.append(runtime_embedding_model) async def delete_embedding_model(self, model_uuid: str) -> None: """Delete an embedding model""" await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_model.EmbeddingModel).where( persistence_model.EmbeddingModel.uuid == model_uuid ) ) await self.ap.model_mgr.remove_embedding_model(model_uuid) async def test_embedding_model(self, model_uuid: str, model_data: dict) -> None: """Test an embedding model""" runtime_embedding_model: model_requester.RuntimeEmbeddingModel | None = None if model_uuid != '_': for model in self.ap.model_mgr.embedding_models: if model.model_entity.uuid == model_uuid: runtime_embedding_model = model break if runtime_embedding_model is None: raise Exception('model not found') else: runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(model_data) await runtime_embedding_model.provider.invoke_embedding( model=runtime_embedding_model, input_text=['Hello, world!'], extra_args={}, ) ================================================ FILE: src/langbot/pkg/api/http/service/monitoring.py ================================================ from __future__ import annotations import uuid import datetime import sqlalchemy from ....core import app from ....entity.persistence import monitoring as persistence_monitoring class MonitoringService: """Monitoring service""" ap: app.Application def __init__(self, ap: app.Application) -> None: self.ap = ap # ========== Recording Methods ========== async def record_message( self, bot_id: str, bot_name: str, pipeline_id: str, pipeline_name: str, message_content: str, session_id: str, status: str = 'success', level: str = 'info', platform: str | None = None, user_id: str | None = None, user_name: str | None = None, runner_name: str | None = None, variables: str | None = None, role: str = 'user', ) -> str: """Record a message""" message_id = str(uuid.uuid4()) message_data = { 'id': message_id, 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'bot_id': bot_id, 'bot_name': bot_name, 'pipeline_id': pipeline_id, 'pipeline_name': pipeline_name, 'message_content': message_content, 'session_id': session_id, 'status': status, 'level': level, 'platform': platform, 'user_id': user_id, 'user_name': user_name, 'runner_name': runner_name, 'variables': variables, 'role': role, } await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_monitoring.MonitoringMessage).values(message_data) ) return message_id async def record_llm_call( self, bot_id: str, bot_name: str, pipeline_id: str, pipeline_name: str, session_id: str, model_name: str, input_tokens: int, output_tokens: int, duration: int, status: str = 'success', cost: float | None = None, error_message: str | None = None, message_id: str | None = None, ) -> str: """Record an LLM call""" call_id = str(uuid.uuid4()) call_data = { 'id': call_id, 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'model_name': model_name, 'input_tokens': input_tokens, 'output_tokens': output_tokens, 'total_tokens': input_tokens + output_tokens, 'duration': duration, 'cost': cost, 'status': status, 'bot_id': bot_id, 'bot_name': bot_name, 'pipeline_id': pipeline_id, 'pipeline_name': pipeline_name, 'session_id': session_id, 'error_message': error_message, 'message_id': message_id, } await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_monitoring.MonitoringLLMCall).values(call_data) ) return call_id async def record_embedding_call( self, model_name: str, prompt_tokens: int, total_tokens: int, duration: int, input_count: int, status: str = 'success', error_message: str | None = None, knowledge_base_id: str | None = None, query_text: str | None = None, session_id: str | None = None, message_id: str | None = None, call_type: str | None = None, ) -> str: """Record an embedding call""" call_id = str(uuid.uuid4()) call_data = { 'id': call_id, 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'model_name': model_name, 'prompt_tokens': prompt_tokens, 'total_tokens': total_tokens, 'duration': duration, 'input_count': input_count, 'status': status, 'error_message': error_message, 'knowledge_base_id': knowledge_base_id, 'query_text': query_text, 'session_id': session_id, 'message_id': message_id, 'call_type': call_type, } await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_monitoring.MonitoringEmbeddingCall).values(call_data) ) return call_id async def record_session_start( self, session_id: str, bot_id: str, bot_name: str, pipeline_id: str, pipeline_name: str, platform: str | None = None, user_id: str | None = None, user_name: str | None = None, ) -> None: """Record a new session""" session_data = { 'session_id': session_id, 'bot_id': bot_id, 'bot_name': bot_name, 'pipeline_id': pipeline_id, 'pipeline_name': pipeline_name, 'message_count': 0, 'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'is_active': True, 'platform': platform, 'user_id': user_id, 'user_name': user_name, } await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data) ) async def update_session_activity( self, session_id: str, pipeline_id: str | None = None, pipeline_name: str | None = None, ) -> bool: """Update session last activity time and increment message count. Also updates pipeline info if the bot's pipeline has changed. Returns: True if session was found and updated, False if session doesn't exist. """ update_values = { 'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'message_count': persistence_monitoring.MonitoringSession.message_count + 1, } # Update pipeline info if provided (handles pipeline switch) if pipeline_id is not None: update_values['pipeline_id'] = pipeline_id if pipeline_name is not None: update_values['pipeline_name'] = pipeline_name result = await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_monitoring.MonitoringSession) .where(persistence_monitoring.MonitoringSession.session_id == session_id) .values(update_values) ) # Check if any rows were updated return result.rowcount > 0 async def record_error( self, bot_id: str, bot_name: str, pipeline_id: str, pipeline_name: str, error_type: str, error_message: str, session_id: str | None = None, stack_trace: str | None = None, message_id: str | None = None, ) -> str: """Record an error""" error_id = str(uuid.uuid4()) error_data = { 'id': error_id, 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'error_type': error_type, 'error_message': error_message, 'bot_id': bot_id, 'bot_name': bot_name, 'pipeline_id': pipeline_id, 'pipeline_name': pipeline_name, 'session_id': session_id, 'stack_trace': stack_trace, 'message_id': message_id, } await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_monitoring.MonitoringError).values(error_data) ) return error_id async def update_message_status( self, message_id: str, status: str, level: str | None = None, variables: str | None = None, ) -> None: """Update message status and optionally variables""" update_values = {'status': status} if level is not None: update_values['level'] = level if variables is not None: update_values['variables'] = variables await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_monitoring.MonitoringMessage) .where(persistence_monitoring.MonitoringMessage.id == message_id) .values(update_values) ) # ========== Query Methods ========== async def get_overview_metrics( self, bot_ids: list[str] | None = None, pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, ) -> dict: """Get overview metrics""" # Build base query conditions message_conditions = [] llm_conditions = [] embedding_conditions = [] session_conditions = [] if bot_ids: message_conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) llm_conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids)) session_conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids)) if pipeline_ids: message_conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids)) llm_conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids)) session_conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) if start_time: message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time) embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time) session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) if end_time: message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time) llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time) embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time) session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) # Total messages message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)) if message_conditions: message_query = message_query.where(sqlalchemy.and_(*message_conditions)) total_messages_result = await self.ap.persistence_mgr.execute_async(message_query) total_messages = total_messages_result.scalar() or 0 # Total LLM calls llm_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id)) if llm_conditions: llm_query = llm_query.where(sqlalchemy.and_(*llm_conditions)) llm_calls_result = await self.ap.persistence_mgr.execute_async(llm_query) llm_calls = llm_calls_result.scalar() or 0 # Total Embedding calls embedding_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.id)) if embedding_conditions: embedding_query = embedding_query.where(sqlalchemy.and_(*embedding_conditions)) embedding_calls_result = await self.ap.persistence_mgr.execute_async(embedding_query) embedding_calls = embedding_calls_result.scalar() or 0 # Total model calls (LLM + Embedding) model_calls = llm_calls + embedding_calls # Success rate (based on messages) success_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)).where( persistence_monitoring.MonitoringMessage.status == 'success' ) if message_conditions: success_query = success_query.where(sqlalchemy.and_(*message_conditions)) success_result = await self.ap.persistence_mgr.execute_async(success_query) success_count = success_result.scalar() or 0 success_rate = (success_count / total_messages * 100) if total_messages > 0 else 100 # Active sessions active_session_query = sqlalchemy.select( sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id) ).where(persistence_monitoring.MonitoringSession.is_active == True) if session_conditions: active_session_query = active_session_query.where(sqlalchemy.and_(*session_conditions)) active_sessions_result = await self.ap.persistence_mgr.execute_async(active_session_query) active_sessions = active_sessions_result.scalar() or 0 return { 'total_messages': total_messages, 'llm_calls': llm_calls, 'embedding_calls': embedding_calls, 'model_calls': model_calls, 'success_rate': round(success_rate, 2), 'active_sessions': active_sessions, } async def get_messages( self, bot_ids: list[str] | None = None, pipeline_ids: list[str] | None = None, session_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, limit: int = 100, offset: int = 0, ) -> tuple[list[dict], int]: """Get messages with filters""" conditions = [] if bot_ids: conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) if pipeline_ids: conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids)) if session_ids: conditions.append(persistence_monitoring.MonitoringMessage.session_id.in_(session_ids)) if start_time: conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time) # Get total count count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)) if conditions: count_query = count_query.where(sqlalchemy.and_(*conditions)) count_result = await self.ap.persistence_mgr.execute_async(count_query) total = count_result.scalar() or 0 # Get messages query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).order_by( persistence_monitoring.MonitoringMessage.timestamp.desc() ) if conditions: query = query.where(sqlalchemy.and_(*conditions)) query = query.limit(limit).offset(offset) result = await self.ap.persistence_mgr.execute_async(query) messages_rows = result.all() serialized = [] for row in messages_rows: # Extract model instance from Row (SQLAlchemy returns Row objects) msg = row[0] if isinstance(row, tuple) else row serialized_msg = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, msg) serialized.append(serialized_msg) return (serialized, total) async def get_llm_calls( self, bot_ids: list[str] | None = None, pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, limit: int = 100, offset: int = 0, ) -> tuple[list[dict], int]: """Get LLM calls with filters""" conditions = [] if bot_ids: conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids)) if pipeline_ids: conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids)) if start_time: conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time) # Get total count count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id)) if conditions: count_query = count_query.where(sqlalchemy.and_(*conditions)) count_result = await self.ap.persistence_mgr.execute_async(count_query) total = count_result.scalar() or 0 # Get LLM calls query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).order_by( persistence_monitoring.MonitoringLLMCall.timestamp.desc() ) if conditions: query = query.where(sqlalchemy.and_(*conditions)) query = query.limit(limit).offset(offset) result = await self.ap.persistence_mgr.execute_async(query) llm_calls_rows = result.all() return ( [ self.ap.persistence_mgr.serialize_model( persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row ) for row in llm_calls_rows ], total, ) async def get_embedding_calls( self, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, knowledge_base_id: str | None = None, limit: int = 100, offset: int = 0, ) -> tuple[list[dict], int]: """Get embedding calls with filters""" conditions = [] if start_time: conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time) if knowledge_base_id: conditions.append(persistence_monitoring.MonitoringEmbeddingCall.knowledge_base_id == knowledge_base_id) # Get total count count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.id)) if conditions: count_query = count_query.where(sqlalchemy.and_(*conditions)) count_result = await self.ap.persistence_mgr.execute_async(count_query) total = count_result.scalar() or 0 # Get embedding calls query = sqlalchemy.select(persistence_monitoring.MonitoringEmbeddingCall).order_by( persistence_monitoring.MonitoringEmbeddingCall.timestamp.desc() ) if conditions: query = query.where(sqlalchemy.and_(*conditions)) query = query.limit(limit).offset(offset) result = await self.ap.persistence_mgr.execute_async(query) embedding_calls_rows = result.all() return ( [ self.ap.persistence_mgr.serialize_model( persistence_monitoring.MonitoringEmbeddingCall, row[0] if isinstance(row, tuple) else row ) for row in embedding_calls_rows ], total, ) async def get_sessions( self, bot_ids: list[str] | None = None, pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, is_active: bool | None = None, limit: int = 100, offset: int = 0, ) -> tuple[list[dict], int]: """Get sessions with filters""" conditions = [] if bot_ids: conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids)) if pipeline_ids: conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) if start_time: conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) if is_active is not None: conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active) # Get total count count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id)) if conditions: count_query = count_query.where(sqlalchemy.and_(*conditions)) count_result = await self.ap.persistence_mgr.execute_async(count_query) total = count_result.scalar() or 0 # Get sessions query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by( persistence_monitoring.MonitoringSession.last_activity.desc() ) if conditions: query = query.where(sqlalchemy.and_(*conditions)) query = query.limit(limit).offset(offset) result = await self.ap.persistence_mgr.execute_async(query) sessions_rows = result.all() return ( [ self.ap.persistence_mgr.serialize_model( persistence_monitoring.MonitoringSession, row[0] if isinstance(row, tuple) else row ) for row in sessions_rows ], total, ) async def get_errors( self, bot_ids: list[str] | None = None, pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, limit: int = 100, offset: int = 0, ) -> tuple[list[dict], int]: """Get errors with filters""" conditions = [] if bot_ids: conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids)) if pipeline_ids: conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids)) if start_time: conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringError.timestamp <= end_time) # Get total count count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringError.id)) if conditions: count_query = count_query.where(sqlalchemy.and_(*conditions)) count_result = await self.ap.persistence_mgr.execute_async(count_query) total = count_result.scalar() or 0 # Get errors query = sqlalchemy.select(persistence_monitoring.MonitoringError).order_by( persistence_monitoring.MonitoringError.timestamp.desc() ) if conditions: query = query.where(sqlalchemy.and_(*conditions)) query = query.limit(limit).offset(offset) result = await self.ap.persistence_mgr.execute_async(query) errors_rows = result.all() return ( [ self.ap.persistence_mgr.serialize_model( persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row ) for row in errors_rows ], total, ) async def get_session_analysis( self, session_id: str, ) -> dict: """Get detailed analysis for a specific session""" # Get session info session_query = sqlalchemy.select(persistence_monitoring.MonitoringSession).where( persistence_monitoring.MonitoringSession.session_id == session_id ) session_result = await self.ap.persistence_mgr.execute_async(session_query) session_row = session_result.first() if not session_row: return { 'session_id': session_id, 'found': False, } session = session_row[0] if isinstance(session_row, tuple) else session_row # Get messages for this session messages_query = ( sqlalchemy.select(persistence_monitoring.MonitoringMessage) .where(persistence_monitoring.MonitoringMessage.session_id == session_id) .order_by(persistence_monitoring.MonitoringMessage.timestamp.asc()) ) messages_result = await self.ap.persistence_mgr.execute_async(messages_query) messages_rows = messages_result.all() # Count messages by status success_messages = 0 error_messages = 0 pending_messages = 0 for row in messages_rows: msg = row[0] if isinstance(row, tuple) else row if msg.status == 'success': success_messages += 1 elif msg.status == 'error': error_messages += 1 elif msg.status == 'pending': pending_messages += 1 # Get LLM calls for this session llm_query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).where( persistence_monitoring.MonitoringLLMCall.session_id == session_id ) llm_result = await self.ap.persistence_mgr.execute_async(llm_query) llm_rows = llm_result.all() # Calculate LLM statistics total_llm_calls = len(llm_rows) total_input_tokens = 0 total_output_tokens = 0 total_tokens = 0 total_duration = 0 success_llm_calls = 0 error_llm_calls = 0 for row in llm_rows: llm_call = row[0] if isinstance(row, tuple) else row total_input_tokens += llm_call.input_tokens total_output_tokens += llm_call.output_tokens total_tokens += llm_call.total_tokens total_duration += llm_call.duration if llm_call.status == 'success': success_llm_calls += 1 else: error_llm_calls += 1 # Get errors for this session error_query = ( sqlalchemy.select(persistence_monitoring.MonitoringError) .where(persistence_monitoring.MonitoringError.session_id == session_id) .order_by(persistence_monitoring.MonitoringError.timestamp.desc()) ) error_result = await self.ap.persistence_mgr.execute_async(error_query) error_rows = error_result.all() errors = [ self.ap.persistence_mgr.serialize_model( persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row ) for row in error_rows ] # Calculate session duration if messages_rows: first_msg = messages_rows[0][0] if isinstance(messages_rows[0], tuple) else messages_rows[0] last_msg = messages_rows[-1][0] if isinstance(messages_rows[-1], tuple) else messages_rows[-1] session_duration_seconds = int((last_msg.timestamp - first_msg.timestamp).total_seconds()) else: session_duration_seconds = 0 return { 'session_id': session_id, 'found': True, 'session': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringSession, session), 'message_stats': { 'total': len(messages_rows), 'success': success_messages, 'error': error_messages, 'pending': pending_messages, }, 'llm_stats': { 'total_calls': total_llm_calls, 'success_calls': success_llm_calls, 'error_calls': error_llm_calls, 'total_input_tokens': total_input_tokens, 'total_output_tokens': total_output_tokens, 'total_tokens': total_tokens, 'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0, }, 'errors': errors, 'session_duration_seconds': session_duration_seconds, } async def get_message_details( self, message_id: str, ) -> dict: """Get detailed information for a specific message including associated LLM calls and errors""" # Get message info message_query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).where( persistence_monitoring.MonitoringMessage.id == message_id ) message_result = await self.ap.persistence_mgr.execute_async(message_query) message_row = message_result.first() if not message_row: return { 'message_id': message_id, 'found': False, } message = message_row[0] if isinstance(message_row, tuple) else message_row # Get LLM calls for this message llm_query = ( sqlalchemy.select(persistence_monitoring.MonitoringLLMCall) .where(persistence_monitoring.MonitoringLLMCall.message_id == message_id) .order_by(persistence_monitoring.MonitoringLLMCall.timestamp.asc()) ) llm_result = await self.ap.persistence_mgr.execute_async(llm_query) llm_rows = llm_result.all() llm_calls = [ self.ap.persistence_mgr.serialize_model( persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row ) for row in llm_rows ] # Calculate LLM statistics total_input_tokens = sum(call.input_tokens for call in llm_rows) total_output_tokens = sum(call.output_tokens for call in llm_rows) total_tokens = sum(call.total_tokens for call in llm_rows) total_duration = sum(call.duration for call in llm_rows) # Get errors for this message error_query = ( sqlalchemy.select(persistence_monitoring.MonitoringError) .where(persistence_monitoring.MonitoringError.message_id == message_id) .order_by(persistence_monitoring.MonitoringError.timestamp.asc()) ) error_result = await self.ap.persistence_mgr.execute_async(error_query) error_rows = error_result.all() errors = [ self.ap.persistence_mgr.serialize_model( persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row ) for row in error_rows ] return { 'message_id': message_id, 'found': True, 'message': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, message), 'llm_calls': llm_calls, 'llm_stats': { 'total_calls': len(llm_rows), 'total_input_tokens': total_input_tokens, 'total_output_tokens': total_output_tokens, 'total_tokens': total_tokens, 'total_duration_ms': total_duration, 'average_duration_ms': int(total_duration / len(llm_rows)) if len(llm_rows) > 0 else 0, }, 'errors': errors, } # ========== Export Methods ========== def _escape_csv_field(self, field: str | None) -> str: """Escape a field for CSV output""" if field is None: return '' # Convert non-string types to string first if not isinstance(field, str): field = str(field) # Replace common escape sequences field = field.replace('\r\n', '\n').replace('\r', '\n') # If field contains comma, double quote, or newline, wrap in quotes if ',' in field or '"' in field or '\n' in field: # Escape double quotes by doubling them field = '"' + field.replace('"', '""') + '"' return field def _format_timestamp(self, dt: datetime.datetime) -> str: """Format datetime to ISO format string""" return dt.strftime('%Y-%m-%d %H:%M:%S') def _extract_message_text(self, message_content: str) -> str: """Extract plain text from message chain JSON""" if not message_content: return '' try: import json message_chain = json.loads(message_content) if not isinstance(message_chain, list): return message_content text_parts = [] for component in message_chain: if not isinstance(component, dict): continue component_type = component.get('type') if component_type == 'Plain': text = component.get('text', '') text_parts.append(text) elif component_type == 'At': display = component.get('display', '') target = component.get('target', '') if display: text_parts.append(f'@{display}') elif target: text_parts.append(f'@{target}') elif component_type == 'AtAll': text_parts.append('@All') elif component_type == 'Image': text_parts.append('[Image]') elif component_type == 'File': name = component.get('name', 'File') text_parts.append(f'[File: {name}]') elif component_type == 'Voice': length = component.get('length', 0) text_parts.append(f'[Voice {length}s]') elif component_type == 'Quote': # Quote content is in 'origin' field origin = component.get('origin', []) if isinstance(origin, list): for item in origin: if isinstance(item, dict) and item.get('type') == 'Plain': text_parts.append(f'> {item.get("text", "")}') elif component_type == 'Source': # Skip Source component continue else: # Other unknown types text_parts.append(f'[{component_type}]') return ''.join(text_parts) except (json.JSONDecodeError, TypeError, KeyError): # If not valid JSON, return as-is return message_content async def export_messages( self, bot_ids: list[str] | None = None, pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, limit: int = 100000, ) -> list[dict]: """Export messages as list of dictionaries for CSV conversion""" conditions = [] if bot_ids: conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) if pipeline_ids: conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids)) if start_time: conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time) query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).order_by( persistence_monitoring.MonitoringMessage.timestamp.desc() ) if conditions: query = query.where(sqlalchemy.and_(*conditions)) query = query.limit(limit) result = await self.ap.persistence_mgr.execute_async(query) rows = result.all() return [ { 'id': row[0].id if isinstance(row, tuple) else row.id, 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, 'runner_name': row[0].runner_name if isinstance(row, tuple) else row.runner_name, 'message_content': row[0].message_content if isinstance(row, tuple) else row.message_content, 'message_text': self._extract_message_text( row[0].message_content if isinstance(row, tuple) else row.message_content ), 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, 'status': row[0].status if isinstance(row, tuple) else row.status, 'level': row[0].level if isinstance(row, tuple) else row.level, 'platform': row[0].platform if isinstance(row, tuple) else row.platform, 'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id, } for row in rows ] async def export_llm_calls( self, bot_ids: list[str] | None = None, pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, limit: int = 100000, ) -> list[dict]: """Export LLM calls as list of dictionaries for CSV conversion""" conditions = [] if bot_ids: conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids)) if pipeline_ids: conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids)) if start_time: conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time) query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).order_by( persistence_monitoring.MonitoringLLMCall.timestamp.desc() ) if conditions: query = query.where(sqlalchemy.and_(*conditions)) query = query.limit(limit) result = await self.ap.persistence_mgr.execute_async(query) rows = result.all() return [ { 'id': row[0].id if isinstance(row, tuple) else row.id, 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), 'model_name': row[0].model_name if isinstance(row, tuple) else row.model_name, 'input_tokens': row[0].input_tokens if isinstance(row, tuple) else row.input_tokens, 'output_tokens': row[0].output_tokens if isinstance(row, tuple) else row.output_tokens, 'total_tokens': row[0].total_tokens if isinstance(row, tuple) else row.total_tokens, 'duration_ms': row[0].duration if isinstance(row, tuple) else row.duration, 'cost': row[0].cost if isinstance(row, tuple) else row.cost, 'status': row[0].status if isinstance(row, tuple) else row.status, 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, 'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id, 'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message, } for row in rows ] async def export_embedding_calls( self, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, knowledge_base_id: str | None = None, limit: int = 100000, ) -> list[dict]: """Export embedding calls as list of dictionaries for CSV conversion""" conditions = [] if start_time: conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time) if knowledge_base_id: conditions.append(persistence_monitoring.MonitoringEmbeddingCall.knowledge_base_id == knowledge_base_id) query = sqlalchemy.select(persistence_monitoring.MonitoringEmbeddingCall).order_by( persistence_monitoring.MonitoringEmbeddingCall.timestamp.desc() ) if conditions: query = query.where(sqlalchemy.and_(*conditions)) query = query.limit(limit) result = await self.ap.persistence_mgr.execute_async(query) rows = result.all() return [ { 'id': row[0].id if isinstance(row, tuple) else row.id, 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), 'model_name': row[0].model_name if isinstance(row, tuple) else row.model_name, 'prompt_tokens': row[0].prompt_tokens if isinstance(row, tuple) else row.prompt_tokens, 'total_tokens': row[0].total_tokens if isinstance(row, tuple) else row.total_tokens, 'duration_ms': row[0].duration if isinstance(row, tuple) else row.duration, 'input_count': row[0].input_count if isinstance(row, tuple) else row.input_count, 'status': row[0].status if isinstance(row, tuple) else row.status, 'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message, 'knowledge_base_id': row[0].knowledge_base_id if isinstance(row, tuple) else row.knowledge_base_id, 'query_text': row[0].query_text if isinstance(row, tuple) else row.query_text, 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, 'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id, 'call_type': row[0].call_type if isinstance(row, tuple) else row.call_type, } for row in rows ] async def export_errors( self, bot_ids: list[str] | None = None, pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, limit: int = 100000, ) -> list[dict]: """Export errors as list of dictionaries for CSV conversion""" conditions = [] if bot_ids: conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids)) if pipeline_ids: conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids)) if start_time: conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringError.timestamp <= end_time) query = sqlalchemy.select(persistence_monitoring.MonitoringError).order_by( persistence_monitoring.MonitoringError.timestamp.desc() ) if conditions: query = query.where(sqlalchemy.and_(*conditions)) query = query.limit(limit) result = await self.ap.persistence_mgr.execute_async(query) rows = result.all() return [ { 'id': row[0].id if isinstance(row, tuple) else row.id, 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), 'error_type': row[0].error_type if isinstance(row, tuple) else row.error_type, 'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message, 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, 'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id, 'stack_trace': row[0].stack_trace if isinstance(row, tuple) else row.stack_trace, } for row in rows ] async def export_sessions( self, bot_ids: list[str] | None = None, pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, limit: int = 100000, ) -> list[dict]: """Export sessions as list of dictionaries for CSV conversion""" conditions = [] if bot_ids: conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids)) if pipeline_ids: conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) if start_time: conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by( persistence_monitoring.MonitoringSession.last_activity.desc() ) if conditions: query = query.where(sqlalchemy.and_(*conditions)) query = query.limit(limit) result = await self.ap.persistence_mgr.execute_async(query) rows = result.all() return [ { 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, 'message_count': row[0].message_count if isinstance(row, tuple) else row.message_count, 'start_time': self._format_timestamp(row[0].start_time if isinstance(row, tuple) else row.start_time), 'last_activity': self._format_timestamp( row[0].last_activity if isinstance(row, tuple) else row.last_activity ), 'is_active': str(row[0].is_active if isinstance(row, tuple) else row.is_active), 'platform': row[0].platform if isinstance(row, tuple) else row.platform, 'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id, } for row in rows ] ================================================ FILE: src/langbot/pkg/api/http/service/pipeline.py ================================================ from __future__ import annotations import uuid import json import sqlalchemy from ....core import app from ....entity.persistence import pipeline as persistence_pipeline default_stage_order = [ 'GroupRespondRuleCheckStage', # 群响应规则检查 'BanSessionCheckStage', # 封禁会话检查 'PreContentFilterStage', # 内容过滤前置阶段 'PreProcessor', # 预处理器 'ConversationMessageTruncator', # 会话消息截断器 'RequireRateLimitOccupancy', # 请求速率限制占用 'MessageProcessor', # 处理器 'ReleaseRateLimitOccupancy', # 释放速率限制占用 'PostContentFilterStage', # 内容过滤后置阶段 'ResponseWrapper', # 响应包装器 'LongTextProcessStage', # 长文本处理 'SendResponseBackStage', # 发送响应 ] class PipelineService: ap: app.Application def __init__(self, ap: app.Application) -> None: self.ap = ap async def get_pipeline_metadata(self) -> list[dict]: return [ self.ap.pipeline_config_meta_trigger, self.ap.pipeline_config_meta_safety, self.ap.pipeline_config_meta_ai, self.ap.pipeline_config_meta_output, ] async def get_pipelines(self, sort_by: str = 'created_at', sort_order: str = 'DESC') -> list[dict]: query = sqlalchemy.select(persistence_pipeline.LegacyPipeline) if sort_by == 'created_at': if sort_order == 'DESC': query = query.order_by(persistence_pipeline.LegacyPipeline.created_at.desc()) else: query = query.order_by(persistence_pipeline.LegacyPipeline.created_at.asc()) elif sort_by == 'updated_at': if sort_order == 'DESC': query = query.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc()) else: query = query.order_by(persistence_pipeline.LegacyPipeline.updated_at.asc()) result = await self.ap.persistence_mgr.execute_async(query) pipelines = result.all() return [ self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) for pipeline in pipelines ] async def get_pipeline(self, pipeline_uuid: str) -> dict | None: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid ) ) pipeline = result.first() if pipeline is None: return None return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str: from ....utils import paths as path_utils # Check limitation limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) max_pipelines = limitation.get('max_pipelines', -1) if max_pipelines >= 0: existing_pipelines = await self.get_pipelines() if len(existing_pipelines) >= max_pipelines: raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached') pipeline_data['uuid'] = str(uuid.uuid4()) pipeline_data['for_version'] = self.ap.ver_mgr.get_current_version() pipeline_data['stages'] = default_stage_order.copy() pipeline_data['is_default'] = default template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') with open(template_path, 'r', encoding='utf-8') as f: pipeline_data['config'] = json.load(f) # Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default if 'extensions_preferences' not in pipeline_data: pipeline_data['extensions_preferences'] = { 'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': [], } await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**pipeline_data) ) pipeline = await self.get_pipeline(pipeline_data['uuid']) await self.ap.pipeline_mgr.load_pipeline(pipeline) return pipeline_data['uuid'] async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None: if 'uuid' in pipeline_data: del pipeline_data['uuid'] if 'for_version' in pipeline_data: del pipeline_data['for_version'] if 'stages' in pipeline_data: del pipeline_data['stages'] if 'is_default' in pipeline_data: del pipeline_data['is_default'] await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_pipeline.LegacyPipeline) .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid) .values(**pipeline_data) ) pipeline = await self.get_pipeline(pipeline_uuid) if 'name' in pipeline_data: from ....entity.persistence import bot as persistence_bot result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid) ) bots = result.all() for bot in bots: bot_data = {'use_pipeline_name': pipeline_data['name']} await self.ap.bot_service.update_bot(bot.uuid, bot_data) await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) await self.ap.pipeline_mgr.load_pipeline(pipeline) # update all conversation that use this pipeline for session in self.ap.sess_mgr.session_list: if session.using_conversation is not None and session.using_conversation.pipeline_uuid == pipeline_uuid: session.using_conversation = None async def delete_pipeline(self, pipeline_uuid: str) -> None: await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_pipeline.LegacyPipeline).where( persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid ) ) await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) async def copy_pipeline(self, pipeline_uuid: str) -> str: """Copy a pipeline with all its configurations""" # Check limitation limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) max_pipelines = limitation.get('max_pipelines', -1) if max_pipelines >= 0: existing_pipelines = await self.get_pipelines() if len(existing_pipelines) >= max_pipelines: raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached') # Get the original pipeline result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid ) ) original_pipeline = result.first() if original_pipeline is None: raise ValueError(f'Pipeline {pipeline_uuid} not found') # Create new pipeline data new_uuid = str(uuid.uuid4()) new_pipeline_data = { 'uuid': new_uuid, 'name': f'{original_pipeline.name} (Copy)', 'description': original_pipeline.description, 'for_version': self.ap.ver_mgr.get_current_version(), 'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(), 'config': original_pipeline.config.copy() if original_pipeline.config else {}, 'is_default': False, 'extensions_preferences': ( original_pipeline.extensions_preferences.copy() if original_pipeline.extensions_preferences else { 'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': [], } ), } # Insert the new pipeline await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**new_pipeline_data) ) # Load the new pipeline pipeline = await self.get_pipeline(new_uuid) await self.ap.pipeline_mgr.load_pipeline(pipeline) return new_uuid async def update_pipeline_extensions( self, pipeline_uuid: str, bound_plugins: list[dict], bound_mcp_servers: list[str] = None, enable_all_plugins: bool = True, enable_all_mcp_servers: bool = True, ) -> None: """Update the bound plugins and MCP servers for a pipeline""" # Get current pipeline result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid ) ) pipeline = result.first() if pipeline is None: raise ValueError(f'Pipeline {pipeline_uuid} not found') # Update extensions_preferences extensions_preferences = pipeline.extensions_preferences or {} extensions_preferences['enable_all_plugins'] = enable_all_plugins extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers extensions_preferences['plugins'] = bound_plugins if bound_mcp_servers is not None: extensions_preferences['mcp_servers'] = bound_mcp_servers await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_pipeline.LegacyPipeline) .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid) .values(extensions_preferences=extensions_preferences) ) # Reload pipeline to apply changes await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) pipeline = await self.get_pipeline(pipeline_uuid) await self.ap.pipeline_mgr.load_pipeline(pipeline) ================================================ FILE: src/langbot/pkg/api/http/service/provider.py ================================================ from __future__ import annotations import uuid import sqlalchemy from ....core import app from ....entity.persistence import model as persistence_model class ModelProviderService: """Service for managing model providers""" ap: app.Application def __init__(self, ap: app.Application) -> None: self.ap = ap async def get_providers(self) -> list[dict]: """Get all providers""" result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider)) providers = result.all() providers_list = [] for p in providers: provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, p) # Parse api_keys if it's a JSON string if isinstance(provider_dict.get('api_keys'), str): import json try: provider_dict['api_keys'] = json.loads(provider_dict['api_keys']) except Exception: provider_dict['api_keys'] = [] providers_list.append(provider_dict) return providers_list async def get_provider(self, provider_uuid: str) -> dict | None: """Get a single provider by UUID""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.ModelProvider).where( persistence_model.ModelProvider.uuid == provider_uuid ) ) provider = result.first() if provider is None: return None provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) # Parse api_keys if it's a JSON string if isinstance(provider_dict.get('api_keys'), str): import json try: provider_dict['api_keys'] = json.loads(provider_dict['api_keys']) except Exception: provider_dict['api_keys'] = [] return provider_dict async def create_provider(self, provider_data: dict) -> str: """Create a new provider""" provider_data['uuid'] = str(uuid.uuid4()) await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data) ) # load to runtime runtime_provider = await self.ap.model_mgr.load_provider(provider_data) self.ap.model_mgr.provider_dict[runtime_provider.provider_entity.uuid] = runtime_provider return provider_data['uuid'] async def update_provider(self, provider_uuid: str, provider_data: dict) -> None: """Update an existing provider""" if 'uuid' in provider_data: del provider_data['uuid'] await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_model.ModelProvider) .where(persistence_model.ModelProvider.uuid == provider_uuid) .values(**provider_data) ) await self.ap.model_mgr.reload_provider(provider_uuid) async def delete_provider(self, provider_uuid: str) -> None: """Delete a provider (only if no models reference it)""" # Check if any models use this provider llm_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.LLMModel).where( persistence_model.LLMModel.provider_uuid == provider_uuid ) ) if llm_result.first() is not None: raise ValueError('Cannot delete provider: LLM models still reference it') embedding_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.EmbeddingModel).where( persistence_model.EmbeddingModel.provider_uuid == provider_uuid ) ) if embedding_result.first() is not None: raise ValueError('Cannot delete provider: Embedding models still reference it') await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_model.ModelProvider).where( persistence_model.ModelProvider.uuid == provider_uuid ) ) await self.ap.model_mgr.remove_provider(provider_uuid) async def get_provider_model_counts(self, provider_uuid: str) -> dict: """Get count of models using this provider""" llm_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(sqlalchemy.func.count()) .select_from(persistence_model.LLMModel) .where(persistence_model.LLMModel.provider_uuid == provider_uuid) ) llm_count = llm_result.scalar() or 0 embedding_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(sqlalchemy.func.count()) .select_from(persistence_model.EmbeddingModel) .where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid) ) embedding_count = embedding_result.scalar() or 0 return {'llm_count': llm_count, 'embedding_count': embedding_count} async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str: """Find existing provider or create new one""" # Try to find existing provider with same config result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_model.ModelProvider).where( persistence_model.ModelProvider.requester == requester, persistence_model.ModelProvider.base_url == base_url, ) ) for provider in result.all(): if sorted(provider.api_keys or []) == sorted(api_keys or []): return provider.uuid # Create new provider provider_name = requester if base_url: try: from urllib.parse import urlparse parsed = urlparse(base_url) provider_name = parsed.netloc or requester except Exception: pass return await self.create_provider( { 'name': provider_name, 'requester': requester, 'base_url': base_url, 'api_keys': api_keys or [], } ) async def update_space_model_provider_api_keys(self, api_key: str) -> None: """Update Space model provider API keys""" await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_model.ModelProvider) .where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000') .values(api_keys=[api_key]) ) await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000') ================================================ FILE: src/langbot/pkg/api/http/service/space.py ================================================ from __future__ import annotations from langbot.pkg.utils import httpclient import typing import datetime import time import sqlalchemy from ....core import app from ....entity.persistence import user from ....entity.dto.space_model import SpaceModel class SpaceService: """Service for interacting with LangBot Space API""" ap: app.Application _credits_cache: typing.Dict[str, typing.Tuple[int, float]] # {user_email: (credits, timestamp)} def __init__(self, ap: app.Application) -> None: self.ap = ap self._credits_cache = {} def _get_space_config(self) -> typing.Dict[str, str]: """Get Space configuration from config file""" space_config = self.ap.instance_config.data.get('space', {}) return { 'url': space_config.get('url', 'https://space.langbot.app'), 'oauth_authorize_url': space_config.get('oauth_authorize_url', 'https://space.langbot.app/auth/authorize'), } async def _get_user_by_email(self, user_email: str) -> user.User | None: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(user.User).where(user.User.user == user_email) ) result_list = result.all() return result_list[0] if result_list else None async def _ensure_valid_token(self, user_email: str) -> str | None: """Ensure access token is valid, refresh if expired. Returns valid access_token or None.""" user_obj = await self._get_user_by_email(user_email) if not user_obj or user_obj.account_type != 'space': return None if not user_obj.space_access_token: return None # Check if token is expired (with 60s buffer) if user_obj.space_access_token_expires_at: if datetime.datetime.now() >= user_obj.space_access_token_expires_at - datetime.timedelta(seconds=60): # Token expired, try to refresh if user_obj.space_refresh_token: try: new_token = await self._refresh_and_save_token(user_obj) return new_token except Exception: return None return None return user_obj.space_access_token async def _refresh_and_save_token(self, user_obj: user.User) -> str: """Refresh token and save to database""" token_data = await self.refresh_token(user_obj.space_refresh_token) access_token = token_data.get('access_token') expires_in = token_data.get('expires_in', 0) if not access_token: raise ValueError('Failed to refresh token') expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None await self.ap.persistence_mgr.execute_async( sqlalchemy.update(user.User) .where(user.User.user == user_obj.user) .values( space_access_token=access_token, space_access_token_expires_at=expires_at, ) ) return access_token # === Raw API calls (no token validation) === def get_oauth_authorize_url(self, redirect_uri: str, state: str = '') -> str: """Get the Space OAuth authorization URL for redirect""" space_config = self._get_space_config() authorize_url = space_config['oauth_authorize_url'] params = f'redirect_uri={redirect_uri}' if state: params += f'&state={state}' return f'{authorize_url}?{params}' async def exchange_oauth_code(self, code: str) -> typing.Dict: """Exchange OAuth authorization code for tokens""" from langbot.pkg.utils import constants space_config = self._get_space_config() space_url = space_config['url'] session = httpclient.get_session() async with session.post( f'{space_url}/api/v1/accounts/oauth/token', json={'code': code, 'instance_id': constants.instance_id}, ) as response: if response.status != 200: raise ValueError(f'Failed to exchange OAuth code: {await response.text()}') data = await response.json() if data.get('code') != 0: raise ValueError(f'Failed to exchange OAuth code: {data.get("msg")}') return data.get('data', {}) async def refresh_token(self, refresh_token: str) -> typing.Dict: """Refresh Space access token""" space_config = self._get_space_config() space_url = space_config['url'] session = httpclient.get_session() async with session.post( f'{space_url}/api/v1/accounts/token/refresh', json={'refresh_token': refresh_token} ) as response: if response.status != 200: raise ValueError(f'Failed to refresh token: {await response.text()}') data = await response.json() if data.get('code') != 0: raise ValueError(f'Failed to refresh token: {data.get("msg")}') return data.get('data', {}) async def get_user_info_raw(self, access_token: str) -> typing.Dict: """Get user info from Space using access token (no validation)""" space_config = self._get_space_config() space_url = space_config['url'] session = httpclient.get_session() async with session.get( f'{space_url}/api/v1/accounts/me', headers={'Authorization': f'Bearer {access_token}'} ) as response: if response.status != 200: raise ValueError(f'Failed to get user info: {await response.text()}') data = await response.json() if data.get('code') != 0: raise ValueError(f'Failed to get user info: {data.get("msg")}') return data.get('data', {}) # === API calls with token validation === async def get_user_info(self, user_email: str) -> typing.Dict | None: """Get user info from Space (with token validation)""" access_token = await self._ensure_valid_token(user_email) if not access_token: return None return await self.get_user_info_raw(access_token) async def get_credits(self, user_email: str, force_refresh: bool = False) -> int | None: """Get Space credits for user with caching (60s TTL)""" cache_ttl = 60 if not force_refresh and user_email in self._credits_cache: credits, ts = self._credits_cache[user_email] if time.time() - ts < cache_ttl: return credits try: info = await self.get_user_info(user_email) if info is None: return None credits = info.get('credits') if credits is not None: self._credits_cache[user_email] = (credits, time.time()) return credits except Exception: return self._credits_cache.get(user_email, (None, 0))[0] async def get_models(self) -> typing.List[SpaceModel]: """Get models from Space""" space_config = self._get_space_config() space_url = space_config['url'] session = httpclient.get_session() async with session.get(f'{space_url}/api/v1/models') as response: if response.status != 200: raise ValueError(f'Failed to get models: {await response.text()}') data = await response.json() if data.get('code') != 0: raise ValueError(f'Failed to get models: {data.get("msg")}') models_data = data.get('data', {}).get('models', []) return [SpaceModel.model_validate(model_dict) for model_dict in models_data] ================================================ FILE: src/langbot/pkg/api/http/service/user.py ================================================ from __future__ import annotations import sqlalchemy import argon2 import jwt import datetime import typing import asyncio from ....core import app from ....entity.persistence import user from ....utils import constants from ....entity.errors import account as account_errors class UserService: ap: app.Application _create_user_lock: asyncio.Lock def __init__(self, ap: app.Application) -> None: self.ap = ap self._create_user_lock = asyncio.Lock() async def is_initialized(self) -> bool: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1)) result_list = result.all() return result_list is not None and len(result_list) > 0 async def create_user(self, user_email: str, password: str) -> None: ph = argon2.PasswordHasher() hashed_password = ph.hash(password) await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local') ) async def get_user_by_email(self, user_email: str) -> user.User | None: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(user.User).where(user.User.user == user_email) ) result_list = result.all() return result_list[0] if result_list is not None and len(result_list) > 0 else None async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None: """Get user by Space account UUID""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(user.User).where(user.User.space_account_uuid == space_account_uuid) ) result_list = result.all() return result_list[0] if result_list is not None and len(result_list) > 0 else None async def authenticate(self, user_email: str, password: str) -> str | None: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(user.User).where(user.User.user == user_email) ) result_list = result.all() if result_list is None or len(result_list) == 0: raise ValueError('用户不存在') user_obj = result_list[0] # Check if this is a Space account if user_obj.account_type == 'space': raise ValueError('请使用 Space 账户登录') ph = argon2.PasswordHasher() ph.verify(user_obj.password, password) return await self.generate_jwt_token(user_email) async def generate_jwt_token(self, user_email: str) -> str: jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] jwt_expire = self.ap.instance_config.data['system']['jwt']['expire'] payload = { 'user': user_email, 'iss': 'LangBot-' + constants.edition, 'exp': datetime.datetime.now() + datetime.timedelta(seconds=jwt_expire), } return jwt.encode(payload, jwt_secret, algorithm='HS256') async def verify_jwt_token(self, token: str) -> str: jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user'] async def reset_password(self, user_email: str, new_password: str) -> None: ph = argon2.PasswordHasher() hashed_password = ph.hash(new_password) await self.ap.persistence_mgr.execute_async( sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) ) async def change_password(self, user_email: str, current_password: str, new_password: str) -> None: ph = argon2.PasswordHasher() user_obj = await self.get_user_by_email(user_email) if user_obj is None: raise ValueError('User not found') # Space accounts cannot change password locally if user_obj.account_type == 'space': raise ValueError('Space account cannot change password locally') ph.verify(user_obj.password, current_password) hashed_password = ph.hash(new_password) await self.ap.persistence_mgr.execute_async( sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) ) # Space user management async def create_or_update_space_user( self, space_account_uuid: str, email: str, access_token: str, refresh_token: str, api_key: str, expires_in: int = 0, ) -> user.User: """Create or update a Space user account (only if system not initialized or user exists)""" expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None async with self._create_user_lock: # Check if user with this Space UUID already exists existing_user = await self.get_user_by_space_account_uuid(space_account_uuid) if existing_user: # Update existing user's tokens await self.ap.persistence_mgr.execute_async( sqlalchemy.update(user.User) .where(user.User.space_account_uuid == space_account_uuid) .values( space_access_token=access_token, space_refresh_token=refresh_token, space_api_key=api_key, space_access_token_expires_at=expires_at, ) ) await self.ap.provider_service.update_space_model_provider_api_keys(api_key) return await self.get_user_by_space_account_uuid(space_account_uuid) # Check if user with same email exists existing_email_user = await self.get_user_by_email(email) if existing_email_user: # Update existing user to link with Space account await self.ap.persistence_mgr.execute_async( sqlalchemy.update(user.User) .where(user.User.user == email) .values( account_type='space', space_account_uuid=space_account_uuid, space_access_token=access_token, space_refresh_token=refresh_token, space_api_key=api_key, space_access_token_expires_at=expires_at, ) ) await self.ap.provider_service.update_space_model_provider_api_keys(api_key) return await self.get_user_by_email(email) # Check if system is already initialized is_initialized = await self.is_initialized() if is_initialized: raise account_errors.AccountEmailMismatchError() # Create new Space user (first time initialization) await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(user.User).values( user=email, password='', # Space users don't have local password account_type='space', space_account_uuid=space_account_uuid, space_access_token=access_token, space_refresh_token=refresh_token, space_api_key=api_key, space_access_token_expires_at=expires_at, ) ) await self.ap.provider_service.update_space_model_provider_api_keys(api_key) return await self.get_user_by_space_account_uuid(space_account_uuid) async def authenticate_space_user( self, access_token: str, refresh_token: str, expires_in: int = 0 ) -> typing.Tuple[str, user.User]: """Authenticate with Space and return JWT token""" # Get user info from Space using raw API (token just obtained, no need to validate) user_info = await self.ap.space_service.get_user_info_raw(access_token) account = user_info.get('account', {}) api_key = user_info.get('api_key', '') space_account_uuid = account.get('uuid') email = account.get('email') if not space_account_uuid or not email: raise ValueError('Invalid Space user info') # Create or update Space user in local database user_obj = await self.create_or_update_space_user( space_account_uuid=space_account_uuid, email=email, access_token=access_token, refresh_token=refresh_token, api_key=api_key, expires_in=expires_in, ) # Generate JWT token jwt_token = await self.generate_jwt_token(email) return jwt_token, user_obj async def get_first_user(self) -> user.User | None: """Get the first user (for single-user mode)""" result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1)) result_list = result.all() return result_list[0] if result_list else None async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None: """Set or change password for a user""" ph = argon2.PasswordHasher() user_obj = await self.get_user_by_email(user_email) if user_obj is None: raise ValueError('User not found') # If user already has a password, verify current password has_password = bool(user_obj.password and user_obj.password.strip()) if has_password: if not current_password: raise ValueError('Current password is required') ph.verify(user_obj.password, current_password) hashed_password = ph.hash(new_password) await self.ap.persistence_mgr.execute_async( sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) ) async def bind_space_account(self, user_email: str, code: str) -> user.User: """Bind Space account to existing local account""" # Exchange code for tokens token_data = await self.ap.space_service.exchange_oauth_code(code) access_token = token_data.get('access_token') refresh_token = token_data.get('refresh_token') expires_in = token_data.get('expires_in', 0) if not access_token: raise ValueError('Failed to get access token from Space') expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None # Get Space user info (token just obtained, use raw API) user_info = await self.ap.space_service.get_user_info_raw(access_token) account = user_info.get('account', {}) api_key = user_info.get('api_key', '') space_account_uuid = account.get('uuid') space_email = account.get('email') if not space_account_uuid or not space_email: raise ValueError('Invalid Space user info') # Check if this Space account is already bound to another user existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid) if existing_space_user and existing_space_user.user != user_email: raise ValueError('This Space account is already bound to another user') # Update local account to Space account await self.ap.persistence_mgr.execute_async( sqlalchemy.update(user.User) .where(user.User.user == user_email) .values( user=space_email, # Update email to Space email account_type='space', space_account_uuid=space_account_uuid, space_access_token=access_token, space_refresh_token=refresh_token, space_api_key=api_key, space_access_token_expires_at=expires_at, ) ) # Update Space model provider API keys await self.ap.provider_service.update_space_model_provider_api_keys(api_key) return await self.get_user_by_email(space_email) ================================================ FILE: src/langbot/pkg/api/http/service/webhook.py ================================================ from __future__ import annotations import sqlalchemy from ....core import app from ....entity.persistence import webhook class WebhookService: ap: app.Application def __init__(self, ap: app.Application) -> None: self.ap = ap async def get_webhooks(self) -> list[dict]: """Get all webhooks""" result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(webhook.Webhook)) webhooks = result.all() return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks] async def create_webhook(self, name: str, url: str, description: str = '', enabled: bool = True) -> dict: """Create a new webhook""" webhook_data = {'name': name, 'url': url, 'description': description, 'enabled': enabled} await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(webhook.Webhook).values(**webhook_data)) # Retrieve the created webhook result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.url == url).order_by(webhook.Webhook.id.desc()) ) created_webhook = result.first() return self.ap.persistence_mgr.serialize_model(webhook.Webhook, created_webhook) async def get_webhook(self, webhook_id: int) -> dict | None: """Get a specific webhook by ID""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == webhook_id) ) wh = result.first() if wh is None: return None return self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) async def update_webhook( self, webhook_id: int, name: str = None, url: str = None, description: str = None, enabled: bool = None ) -> None: """Update a webhook's metadata""" update_data = {} if name is not None: update_data['name'] = name if url is not None: update_data['url'] = url if description is not None: update_data['description'] = description if enabled is not None: update_data['enabled'] = enabled if update_data: await self.ap.persistence_mgr.execute_async( sqlalchemy.update(webhook.Webhook).where(webhook.Webhook.id == webhook_id).values(**update_data) ) async def delete_webhook(self, webhook_id: int) -> None: """Delete a webhook""" await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(webhook.Webhook).where(webhook.Webhook.id == webhook_id) ) async def get_enabled_webhooks(self) -> list[dict]: """Get all enabled webhooks""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.enabled == True) ) webhooks = result.all() return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks] ================================================ FILE: src/langbot/pkg/command/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/command/cmdmgr.py ================================================ from __future__ import annotations import typing from ..core import app from . import operator from ..utils import importutil import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors # 引入所有算子以便注册 from . import operators importutil.import_modules_in_pkg(operators) class CommandManager: ap: app.Application cmd_list: list[operator.CommandOperator] """ Runtime command list, flat storage, each object contains a reference to the corresponding child node """ def __init__(self, ap: app.Application): self.ap = ap async def initialize(self): # 设置各个类的路径 def set_path(cls: operator.CommandOperator, ancestors: list[str]): cls.path = '.'.join(ancestors + [cls.name]) for op in operator.preregistered_operators: if op.parent_class == cls: set_path(op, ancestors + [cls.name]) for cls in operator.preregistered_operators: if cls.parent_class is None: set_path(cls, []) # 应用命令权限配置 # for cls in operator.preregistered_operators: # if cls.path in self.ap.instance_config.data['command']['privilege']: # cls.lowest_privilege = self.ap.instance_config.data['command']['privilege'][cls.path] # 实例化所有类 self.cmd_list = [cls(self.ap) for cls in operator.preregistered_operators] # 设置所有类的子节点 for cmd in self.cmd_list: cmd.children = [child for child in self.cmd_list if child.parent_class == cmd.__class__] # 初始化所有类 for cmd in self.cmd_list: await cmd.initialize() async def _execute( self, context: command_context.ExecuteContext, operator_list: list[operator.CommandOperator], operator: operator.CommandOperator = None, bound_plugins: list[str] | None = None, ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: """执行命令""" command_list = await self.ap.plugin_connector.list_commands(bound_plugins) for command in command_list: if command.metadata.name == context.command: async for ret in self.ap.plugin_connector.execute_command(context, bound_plugins): yield ret break else: yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(context.command)) async def execute( self, command_text: str, full_command_text: str, query: pipeline_query.Query, session: provider_session.Session, ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: """执行命令""" privilege = 1 if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']: privilege = 2 ctx = command_context.ExecuteContext( query_id=query.query_id, session=session, command_text=command_text, full_command_text=full_command_text, command='', crt_command='', params=command_text.split(' '), crt_params=command_text.split(' '), privilege=privilege, ) ctx.command = ctx.params[0] ctx.shift() # Get bound plugins from query bound_plugins = query.variables.get('_pipeline_bound_plugins', None) async for ret in self._execute(ctx, self.cmd_list, bound_plugins=bound_plugins): yield ret ================================================ FILE: src/langbot/pkg/command/operator.py ================================================ from __future__ import annotations import typing import abc from ..core import app from langbot_plugin.api.entities.builtin.command import context as command_context preregistered_operators: list[typing.Type[CommandOperator]] = [] """预注册命令算子列表。在初始化时,所有算子类会被注册到此列表中。""" def operator_class( name: str, help: str = '', usage: str = None, alias: list[str] = [], privilege: int = 1, # 1为普通用户,2为管理员 parent_class: typing.Type[CommandOperator] = None, ) -> typing.Callable[[typing.Type[CommandOperator]], typing.Type[CommandOperator]]: """命令类装饰器 Args: name (str): 名称 help (str, optional): 帮助信息. Defaults to "". usage (str, optional): 使用说明. Defaults to None. alias (list[str], optional): 别名. Defaults to []. privilege (int, optional): 权限,1为普通用户可用,2为仅管理员可用. Defaults to 1. parent_class (typing.Type[CommandOperator], optional): 父节点,若为None则为顶级命令. Defaults to None. Returns: typing.Callable[[typing.Type[CommandOperator]], typing.Type[CommandOperator]]: 装饰器 """ def decorator(cls: typing.Type[CommandOperator]) -> typing.Type[CommandOperator]: assert issubclass(cls, CommandOperator) cls.name = name cls.alias = alias cls.help = help cls.usage = usage cls.parent_class = parent_class cls.lowest_privilege = privilege preregistered_operators.append(cls) return cls return decorator class CommandOperator(metaclass=abc.ABCMeta): """命令算子抽象类 以下的参数均不需要在子类中设置,只需要在使用装饰器注册类时作为参数传递即可。 命令支持级联,即一个命令可以有多个子命令,子命令可以有子命令,以此类推。 处理命令时,若有子命令,会以当前参数列表的第一个参数去匹配子命令,若匹配成功,则转移到子命令中执行。 若没有匹配成功或没有子命令,则执行当前命令。 """ ap: app.Application name: str """名称,搜索到时若符合则使用""" path: str """路径,所有父节点的name的连接,用于定义命令权限,由管理器在初始化时自动设置。 """ alias: list[str] """同name""" help: str """此节点的帮助信息""" usage: str = None """用法""" parent_class: typing.Union[typing.Type[CommandOperator], None] = None """父节点类。标记以供管理器在初始化时编织父子关系。""" lowest_privilege: int = 0 """最低权限。若权限低于此值,则不予执行。""" children: list[CommandOperator] """子节点。解析命令时,若节点有子节点,则以下一个参数去匹配子节点, 若有匹配中的,转移到子节点中执行,若没有匹配中的或没有子节点,执行此节点。""" def __init__(self, ap: app.Application): self.ap = ap self.children = [] async def initialize(self): pass @abc.abstractmethod async def execute( self, context: command_context.ExecuteContext ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: """实现此方法以执行命令 支持多次yield以返回多个结果。 例如:一个安装插件的命令,可能会有下载、解压、安装等多个步骤,每个步骤都可以返回一个结果。 Args: context (command_context.ExecuteContext): 命令执行上下文 Yields: command_context.CommandReturn: 命令返回封装 """ pass ================================================ FILE: src/langbot/pkg/command/operators/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/command/operators/delc.py ================================================ # from __future__ import annotations # import typing # from .. import operator # from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors # @operator.operator_class(name='del', help='删除当前会话的历史记录', usage='!del <序号>\n!del all') # class DelOperator(operator.CommandOperator): # async def execute( # self, context: command_context.ExecuteContext # ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: # if context.session.conversations: # delete_index = 0 # if len(context.crt_params) > 0: # try: # delete_index = int(context.crt_params[0]) # except Exception: # yield command_context.CommandReturn(error=command_errors.CommandOperationError('索引必须是整数')) # return # if delete_index < 0 or delete_index >= len(context.session.conversations): # yield command_context.CommandReturn(error=command_errors.CommandOperationError('索引超出范围')) # return # # 倒序 # to_delete_index = len(context.session.conversations) - 1 - delete_index # if context.session.conversations[to_delete_index] == context.session.using_conversation: # context.session.using_conversation = None # del context.session.conversations[to_delete_index] # yield command_context.CommandReturn(text=f'已删除对话: {delete_index}') # else: # yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话')) # @operator.operator_class(name='all', help='删除此会话的所有历史记录', parent_class=DelOperator) # class DelAllOperator(operator.CommandOperator): # async def execute( # self, context: command_context.ExecuteContext # ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: # context.session.conversations = [] # context.session.using_conversation = None # yield command_context.CommandReturn(text='已删除所有对话') ================================================ FILE: src/langbot/pkg/command/operators/last.py ================================================ # from __future__ import annotations # import typing # from .. import operator # from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors # @operator.operator_class(name='last', help='切换到前一个对话', usage='!last') # class LastOperator(operator.CommandOperator): # async def execute( # self, context: command_context.ExecuteContext # ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: # if context.session.conversations: # # 找到当前会话的上一个会话 # for index in range(len(context.session.conversations) - 1, -1, -1): # if context.session.conversations[index] == context.session.using_conversation: # if index == 0: # yield command_context.CommandReturn( # error=command_errors.CommandOperationError('已经是第一个对话了') # ) # return # else: # context.session.using_conversation = context.session.conversations[index - 1] # time_str = context.session.using_conversation.create_time.strftime('%Y-%m-%d %H:%M:%S') # yield command_context.CommandReturn( # text=f'已切换到上一个对话: {index} {time_str}: {context.session.using_conversation.messages[0].readable_str()}' # ) # return # else: # yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话')) ================================================ FILE: src/langbot/pkg/command/operators/list.py ================================================ # from __future__ import annotations # import typing # from .. import operator # from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors # @operator.operator_class(name='list', help='列出此会话中的所有历史对话', usage='!list\n!list <页码>') # class ListOperator(operator.CommandOperator): # async def execute( # self, context: command_context.ExecuteContext # ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: # page = 0 # if len(context.crt_params) > 0: # try: # page = int(context.crt_params[0] - 1) # except Exception: # yield command_context.CommandReturn(error=command_errors.CommandOperationError('页码应为整数')) # return # record_per_page = 10 # content = '' # index = 0 # using_conv_index = 0 # for conv in context.session.conversations[::-1]: # time_str = conv.create_time.strftime('%Y-%m-%d %H:%M:%S') # if conv == context.session.using_conversation: # using_conv_index = index # if index >= page * record_per_page and index < (page + 1) * record_per_page: # content += ( # f'{index} {time_str}: {conv.messages[0].readable_str() if len(conv.messages) > 0 else "无内容"}\n' # ) # index += 1 # if content == '': # content = '无' # else: # if context.session.using_conversation is None: # content += '\n当前处于新会话' # else: # content += f'\n当前会话: {using_conv_index} {context.session.using_conversation.create_time.strftime("%Y-%m-%d %H:%M:%S")}: {context.session.using_conversation.messages[0].readable_str() if len(context.session.using_conversation.messages) > 0 else "无内容"}' # yield command_context.CommandReturn(text=f'第 {page + 1} 页 (时间倒序):\n{content}') ================================================ FILE: src/langbot/pkg/command/operators/next.py ================================================ # from __future__ import annotations # import typing # from .. import operator # from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors # @operator.operator_class(name='next', help='切换到后一个对话', usage='!next') # class NextOperator(operator.CommandOperator): # async def execute( # self, context: command_context.ExecuteContext # ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: # if context.session.conversations: # # 找到当前会话的下一个会话 # for index in range(len(context.session.conversations)): # if context.session.conversations[index] == context.session.using_conversation: # if index == len(context.session.conversations) - 1: # yield command_context.CommandReturn( # error=command_errors.CommandOperationError('已经是最后一个对话了') # ) # return # else: # context.session.using_conversation = context.session.conversations[index + 1] # time_str = context.session.using_conversation.create_time.strftime('%Y-%m-%d %H:%M:%S') # yield command_context.CommandReturn( # text=f'已切换到后一个对话: {index} {time_str}: {context.session.using_conversation.messages[0].content}' # ) # return # else: # yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话')) ================================================ FILE: src/langbot/pkg/command/operators/prompt.py ================================================ # from __future__ import annotations # import typing # from .. import operator # from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors # @operator.operator_class(name='prompt', help='查看当前对话的前文', usage='!prompt') # class PromptOperator(operator.CommandOperator): # async def execute( # self, context: command_context.ExecuteContext # ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: # """执行""" # if context.session.using_conversation is None: # yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话')) # else: # reply_str = '当前对话所有内容:\n\n' # for msg in context.session.using_conversation.messages: # reply_str += f'{msg.role}: {msg.content}\n' # yield command_context.CommandReturn(text=reply_str) ================================================ FILE: src/langbot/pkg/command/operators/resend.py ================================================ # from __future__ import annotations # import typing # from .. import operator # from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors # @operator.operator_class(name='resend', help='重发当前会话的最后一条消息', usage='!resend') # class ResendOperator(operator.CommandOperator): # async def execute( # self, context: command_context.ExecuteContext # ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: # # 回滚到最后一条用户message前 # if context.session.using_conversation is None: # yield command_context.CommandReturn(error=command_errors.CommandError('当前没有对话')) # else: # conv_msg = context.session.using_conversation.messages # # 倒序一直删到最后一条用户message # while len(conv_msg) > 0 and conv_msg[-1].role != 'user': # conv_msg.pop() # if len(conv_msg) > 0: # # 删除最后一条用户message # conv_msg.pop() # # 不重发了,提示用户已删除就行了 # yield command_context.CommandReturn(text='已删除最后一次请求记录') ================================================ FILE: src/langbot/pkg/config/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/config/impls/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/config/impls/json.py ================================================ import os import json import importlib.resources as resources from langbot.pkg.config import model as file_model class JSONConfigFile(file_model.ConfigFile): """JSON config file""" def __init__( self, config_file_name: str, template_resource_name: str = None, template_data: dict = None, ) -> None: self.config_file_name = config_file_name self.template_resource_name = template_resource_name self.template_data = template_data def exists(self) -> bool: return os.path.exists(self.config_file_name) async def get_template_file_str(self) -> str: if self.template_resource_name is None: return None with ( resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f ): return f.read() async def create(self): if await self.get_template_file_str() is not None: with open(self.config_file_name, 'w', encoding='utf-8') as f: f.write(await self.get_template_file_str()) elif self.template_data is not None: with open(self.config_file_name, 'w', encoding='utf-8') as f: json.dump(self.template_data, f, indent=4, ensure_ascii=False) else: raise ValueError('template_file_name or template_data must be provided') async def load(self, completion: bool = True) -> dict: if not self.exists(): await self.create() template_file_str = await self.get_template_file_str() if template_file_str is not None: self.template_data = json.loads(template_file_str) with open(self.config_file_name, 'r', encoding='utf-8') as f: try: cfg = json.load(f) except json.JSONDecodeError as e: raise Exception(f'Syntax error in config file {self.config_file_name}: {e}') if completion: for key in self.template_data: if key not in cfg: cfg[key] = self.template_data[key] return cfg async def save(self, cfg: dict): with open(self.config_file_name, 'w', encoding='utf-8') as f: json.dump(cfg, f, indent=4, ensure_ascii=False) def save_sync(self, cfg: dict): with open(self.config_file_name, 'w', encoding='utf-8') as f: json.dump(cfg, f, indent=4, ensure_ascii=False) ================================================ FILE: src/langbot/pkg/config/impls/pymodule.py ================================================ import os import shutil import importlib import logging from .. import model as file_model class PythonModuleConfigFile(file_model.ConfigFile): """Python module config file""" config_file_name: str = None """Config file name""" template_file_name: str = None """Template file name""" def __init__(self, config_file_name: str, template_file_name: str) -> None: self.config_file_name = config_file_name self.template_file_name = template_file_name def exists(self) -> bool: return os.path.exists(self.config_file_name) async def create(self): shutil.copyfile(self.template_file_name, self.config_file_name) async def load(self, completion: bool = True) -> dict: module_name = os.path.splitext(os.path.basename(self.config_file_name))[0] module = importlib.import_module(module_name) cfg = {} allowed_types = (int, float, str, bool, list, dict) for key in dir(module): if key.startswith('__'): continue if not isinstance(getattr(module, key), allowed_types): continue cfg[key] = getattr(module, key) # complete from template module file if completion: module_name = os.path.splitext(os.path.basename(self.template_file_name))[0] module = importlib.import_module(module_name) for key in dir(module): if key.startswith('__'): continue if not isinstance(getattr(module, key), allowed_types): continue if key not in cfg: cfg[key] = getattr(module, key) return cfg async def save(self, data: dict): logging.warning('Python module config file does not support saving') def save_sync(self, data: dict): logging.warning('Python module config file does not support saving') ================================================ FILE: src/langbot/pkg/config/impls/yaml.py ================================================ import os import yaml import importlib.resources as resources from langbot.pkg.config import model as file_model class YAMLConfigFile(file_model.ConfigFile): """YAML config file""" def __init__( self, config_file_name: str, template_resource_name: str = None, template_data: dict = None, ) -> None: self.config_file_name = config_file_name self.template_resource_name = template_resource_name self.template_data = template_data def exists(self) -> bool: return os.path.exists(self.config_file_name) async def get_template_file_str(self) -> str: if self.template_resource_name is None: return None with ( resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f ): return f.read() async def create(self): if await self.get_template_file_str() is not None: with open(self.config_file_name, 'w', encoding='utf-8') as f: f.write(await self.get_template_file_str()) elif self.template_data is not None: with open(self.config_file_name, 'w', encoding='utf-8') as f: yaml.dump(self.template_data, f, indent=4, allow_unicode=True) else: raise ValueError('template_file_name or template_data must be provided') async def load(self, completion: bool = True) -> dict: if not self.exists(): await self.create() template_file_str = await self.get_template_file_str() if template_file_str is not None: self.template_data = yaml.load(template_file_str, Loader=yaml.FullLoader) with open(self.config_file_name, 'r', encoding='utf-8') as f: try: cfg = yaml.load(f, Loader=yaml.FullLoader) except yaml.YAMLError as e: raise Exception(f'Syntax error in config file {self.config_file_name}: {e}') if completion: for key in self.template_data: if key not in cfg: cfg[key] = self.template_data[key] return cfg async def save(self, cfg: dict): with open(self.config_file_name, 'w', encoding='utf-8') as f: yaml.dump(cfg, f, indent=4, allow_unicode=True) def save_sync(self, cfg: dict): with open(self.config_file_name, 'w', encoding='utf-8') as f: yaml.dump(cfg, f, indent=4, allow_unicode=True) ================================================ FILE: src/langbot/pkg/config/manager.py ================================================ from __future__ import annotations from . import model as file_model from .impls import pymodule, json as json_file, yaml as yaml_file class ConfigManager: """Config file manager""" name: str = None """Config manager name""" description: str = None """Config manager description""" schema: dict = None """Config file schema Must conform to JSON Schema Draft 7 specification """ file: file_model.ConfigFile = None """Config file instance""" data: dict = None """Config data""" doc_link: str = None """Config file documentation link""" def __init__(self, cfg_file: file_model.ConfigFile) -> None: self.file = cfg_file self.data = {} async def load_config(self, completion: bool = True): self.data = await self.file.load(completion=completion) async def dump_config(self): await self.file.save(self.data) def dump_config_sync(self): self.file.save_sync(self.data) async def load_python_module_config(config_name: str, template_name: str, completion: bool = True) -> ConfigManager: """Load Python module config file Args: config_name (str): Config file name template_name (str): Template file name completion (bool): Whether to automatically complete the config file in memory Returns: ConfigManager: Config file manager """ cfg_inst = pymodule.PythonModuleConfigFile(config_name, template_name) cfg_mgr = ConfigManager(cfg_inst) await cfg_mgr.load_config(completion=completion) return cfg_mgr async def load_json_config( config_name: str, template_resource_name: str = None, template_data: dict = None, completion: bool = True, ) -> ConfigManager: """Load JSON config file Args: config_name (str): Config file name template_resource_name (str): Template resource name template_data (dict): Template data completion (bool): Whether to automatically complete the config file in memory """ cfg_inst = json_file.JSONConfigFile(config_name, template_resource_name, template_data) cfg_mgr = ConfigManager(cfg_inst) await cfg_mgr.load_config(completion=completion) return cfg_mgr async def load_yaml_config( config_name: str, template_resource_name: str = None, template_data: dict = None, completion: bool = True, ) -> ConfigManager: """Load YAML config file Args: config_name (str): Config file name template_resource_name (str): Template resource name template_data (dict): Template data completion (bool): Whether to automatically complete the config file in memory Returns: ConfigManager: Config file manager """ cfg_inst = yaml_file.YAMLConfigFile(config_name, template_resource_name, template_data) cfg_mgr = ConfigManager(cfg_inst) await cfg_mgr.load_config(completion=completion) return cfg_mgr ================================================ FILE: src/langbot/pkg/config/model.py ================================================ import abc class ConfigFile(metaclass=abc.ABCMeta): """Config file abstract class""" config_file_name: str = None """Config file name""" template_file_name: str = None """Template file name""" template_data: dict = None """Template data""" @abc.abstractmethod def exists(self) -> bool: pass @abc.abstractmethod async def create(self): pass @abc.abstractmethod async def load(self, completion: bool = True) -> dict: pass @abc.abstractmethod async def save(self, data: dict): pass @abc.abstractmethod def save_sync(self, data: dict): pass ================================================ FILE: src/langbot/pkg/core/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/core/app.py ================================================ from __future__ import annotations import logging import asyncio import traceback import os from ..platform import botmgr as im_mgr from ..platform.webhook_pusher import WebhookPusher from ..provider.session import sessionmgr as llm_session_mgr from ..provider.modelmgr import modelmgr as llm_model_mgr from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr from ..config import manager as config_mgr from ..command import cmdmgr from ..plugin import connector as plugin_connector from ..pipeline import pool from ..pipeline import controller, pipelinemgr from ..pipeline import aggregator as message_aggregator from ..utils import version as version_mgr, proxy as proxy_mgr from ..persistence import mgr as persistencemgr from ..api.http.controller import main as http_controller from ..api.http.service import user as user_service from ..api.http.service import space as space_service from ..api.http.service import model as model_service from ..api.http.service import provider as provider_service from ..api.http.service import pipeline as pipeline_service from ..api.http.service import bot as bot_service from ..api.http.service import knowledge as knowledge_service from ..api.http.service import mcp as mcp_service from ..api.http.service import apikey as apikey_service from ..api.http.service import webhook as webhook_service from ..api.http.service import monitoring as monitoring_service from ..discover import engine as discover_engine from ..storage import mgr as storagemgr from ..utils import logcache from . import taskmgr from . import entities as core_entities from ..rag.knowledge import kbmgr as rag_mgr from ..rag.service import RAGRuntimeService from ..vector import mgr as vectordb_mgr from ..telemetry import telemetry as telemetry_module from ..survey import manager as survey_module class Application: """Runtime application object and context""" event_loop: asyncio.AbstractEventLoop = None # asyncio_tasks: list[asyncio.Task] = [] task_mgr: taskmgr.AsyncTaskManager = None discover: discover_engine.ComponentDiscoveryEngine = None platform_mgr: im_mgr.PlatformManager = None webhook_pusher: WebhookPusher = None cmd_mgr: cmdmgr.CommandManager = None sess_mgr: llm_session_mgr.SessionManager = None model_mgr: llm_model_mgr.ModelManager = None rag_mgr: rag_mgr.RAGManager = None rag_runtime_service: RAGRuntimeService = None # TODO move to pipeline tool_mgr: llm_tool_mgr.ToolManager = None # ======= Config manager ======= command_cfg: config_mgr.ConfigManager = None # deprecated pipeline_cfg: config_mgr.ConfigManager = None # deprecated platform_cfg: config_mgr.ConfigManager = None # deprecated provider_cfg: config_mgr.ConfigManager = None # deprecated system_cfg: config_mgr.ConfigManager = None # deprecated instance_config: config_mgr.ConfigManager = None instance_id: config_mgr.ConfigManager = None # used to identify the instance # ======= Metadata config manager ======= sensitive_meta: config_mgr.ConfigManager = None pipeline_config_meta_trigger: config_mgr.ConfigManager = None pipeline_config_meta_safety: config_mgr.ConfigManager = None pipeline_config_meta_ai: config_mgr.ConfigManager = None pipeline_config_meta_output: config_mgr.ConfigManager = None # ========================= plugin_connector: plugin_connector.PluginRuntimeConnector = None query_pool: pool.QueryPool = None msg_aggregator: message_aggregator.MessageAggregator = None ctrl: controller.Controller = None pipeline_mgr: pipelinemgr.PipelineManager = None ver_mgr: version_mgr.VersionManager = None proxy_mgr: proxy_mgr.ProxyManager = None logger: logging.Logger = None persistence_mgr: persistencemgr.PersistenceManager = None vector_db_mgr: vectordb_mgr.VectorDBManager = None http_ctrl: http_controller.HTTPController = None log_cache: logcache.LogCache = None storage_mgr: storagemgr.StorageMgr = None # ========= HTTP Services ========= user_service: user_service.UserService = None space_service: space_service.SpaceService = None llm_model_service: model_service.LLMModelsService = None embedding_models_service: model_service.EmbeddingModelsService = None provider_service: provider_service.ModelProviderService = None pipeline_service: pipeline_service.PipelineService = None bot_service: bot_service.BotService = None knowledge_service: knowledge_service.KnowledgeService = None mcp_service: mcp_service.MCPService = None apikey_service: apikey_service.ApiKeyService = None webhook_service: webhook_service.WebhookService = None telemetry: telemetry_module.TelemetryManager = None survey: survey_module.SurveyManager = None monitoring_service: monitoring_service.MonitoringService = None def __init__(self): pass async def initialize(self): pass async def run(self): try: await self.plugin_connector.initialize_plugins() # 后续可能会允许动态重启其他任务 # 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程 async def never_ending(): while True: await asyncio.sleep(1) self.task_mgr.create_task( self.platform_mgr.run(), name='platform-manager', scopes=[ core_entities.LifecycleControlScope.APPLICATION, core_entities.LifecycleControlScope.PLATFORM, ], ) self.task_mgr.create_task( self.ctrl.run(), name='query-controller', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) self.task_mgr.create_task( self.http_ctrl.run(), name='http-api-controller', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) self.task_mgr.create_task( never_ending(), name='never-ending-task', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) await self.print_web_access_info() await self.task_mgr.wait_all() except asyncio.CancelledError: pass except Exception as e: self.logger.error(f'Application runtime fatal exception: {e}') self.logger.debug(f'Traceback: {traceback.format_exc()}') def dispose(self): self.plugin_connector.dispose() async def print_web_access_info(self): """Print access webui tips""" from ..utils import paths frontend_path = paths.get_frontend_path() if not os.path.exists(frontend_path): self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh') self.logger.warning( 'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en' ) return host_ip = '127.0.0.1' port = self.instance_config.data['api']['port'] tips = f""" ======================================= ✨ Access WebUI / 访问管理面板 🏠 Local Address: http://{host_ip}:{port}/ 🌐 Public Address: http://:{port}/ 📌 Running this program in a container? Please ensure that the {port} port is exposed ======================================= """.strip() for line in tips.split('\n'): self.logger.info(line) ================================================ FILE: src/langbot/pkg/core/boot.py ================================================ from __future__ import annotations import traceback import asyncio import os from . import app from . import stage from ..utils import constants, importutil # Import startup stage implementation to register from . import stages importutil.import_modules_in_pkg(stages) stage_order = [ 'LoadConfigStage', 'MigrationStage', 'GenKeysStage', 'SetupLoggerStage', 'BuildAppStage', 'ShowNotesStage', ] async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application: # Determine if it is debug mode if 'DEBUG' in os.environ and os.environ['DEBUG'] in ['true', '1']: constants.debug_mode = True ap = app.Application() ap.event_loop = loop # Execute startup stage for stage_name in stage_order: stage_cls = stage.preregistered_stages[stage_name] stage_inst = stage_cls() await stage_inst.run(ap) await ap.initialize() return ap async def main(loop: asyncio.AbstractEventLoop): try: # Hang system signal processing import signal def signal_handler(sig, frame): app_inst.dispose() print('[Signal] Program exit.') os._exit(0) signal.signal(signal.SIGINT, signal_handler) app_inst = await make_app(loop) await app_inst.run() except Exception: traceback.print_exc() ================================================ FILE: src/langbot/pkg/core/bootutils/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/core/bootutils/config.py ================================================ from __future__ import annotations from ...config import manager as config_mgr load_python_module_config = config_mgr.load_python_module_config load_json_config = config_mgr.load_json_config load_yaml_config = config_mgr.load_yaml_config ================================================ FILE: src/langbot/pkg/core/bootutils/deps.py ================================================ import importlib.util import pip import os from ...utils import pkgmgr # Check dependencies to prevent users from not installing # Left is the import name, right is the dependency name required_deps = { 'requests': 'requests', 'openai': 'openai', 'anthropic': 'anthropic', 'colorlog': 'colorlog', 'aiocqhttp': 'aiocqhttp', 'botpy': 'qq-botpy-rc', 'PIL': 'pillow', 'nakuru': 'nakuru-project-idk', 'tiktoken': 'tiktoken', 'yaml': 'pyyaml', 'aiohttp': 'aiohttp', 'psutil': 'psutil', 'async_lru': 'async-lru', 'ollama': 'ollama', 'quart': 'quart', 'quart_cors': 'quart-cors', 'sqlalchemy': 'sqlalchemy[asyncio]', 'aiosqlite': 'aiosqlite', 'aiofiles': 'aiofiles', 'aioshutil': 'aioshutil', 'argon2': 'argon2-cffi', 'jwt': 'pyjwt', 'Crypto': 'pycryptodome', 'lark_oapi': 'lark-oapi', 'discord': 'discord.py', 'cryptography': 'cryptography', 'gewechat_client': 'gewechat-client', 'dingtalk_stream': 'dingtalk_stream', 'dashscope': 'dashscope', 'telegram': 'python-telegram-bot', 'certifi': 'certifi', 'mcp': 'mcp', 'sqlmodel': 'sqlmodel', 'telegramify_markdown': 'telegramify-markdown', 'slack_sdk': 'slack_sdk', 'asyncpg': 'asyncpg', } async def check_deps() -> list[str]: global required_deps missing_deps = [] for dep in required_deps: # Use find_spec instead of __import__ to avoid actually loading # all modules into memory. find_spec only checks if the module # can be found, without executing module-level code. if importlib.util.find_spec(dep) is None: missing_deps.append(dep) return missing_deps async def install_deps(deps: list[str]): global required_deps for dep in deps: pip.main(['install', required_deps[dep]]) async def precheck_plugin_deps(): print('[Startup] Prechecking plugin dependencies...') # Only execute plugin dependency installation when the plugins directory exists if os.path.exists('plugins'): for dir in os.listdir('plugins'): subdir = os.path.join('plugins', dir) if not os.path.isdir(subdir): continue if 'requirements.txt' in os.listdir(subdir): pkgmgr.install_requirements( os.path.join(subdir, 'requirements.txt'), extra_params=[], ) ================================================ FILE: src/langbot/pkg/core/bootutils/files.py ================================================ from __future__ import annotations import os import shutil required_files = { 'data/config.yaml': 'templates/config.yaml', } required_paths = [ 'temp', 'data', 'data/metadata', 'data/logs', 'data/labels', ] async def generate_files() -> list[str]: global required_files, required_paths from ...utils import paths as path_utils for required_paths in required_paths: if not os.path.exists(required_paths): os.mkdir(required_paths) generated_files = [] for file in required_files: if not os.path.exists(file): template_path = path_utils.get_resource_path(required_files[file]) shutil.copyfile(template_path, file) generated_files.append(file) return generated_files ================================================ FILE: src/langbot/pkg/core/bootutils/log.py ================================================ import logging import logging.handlers import sys import time import colorlog from ...utils import constants log_colors_config = { 'DEBUG': 'green', # cyan white 'INFO': 'white', 'WARNING': 'yellow', 'ERROR': 'red', 'CRITICAL': 'cyan', } # Log rotation configuration to prevent unbounded log file growth LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max) async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger: # Remove all existing loggers for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) level = logging.INFO if constants.debug_mode: level = logging.DEBUG log_file_name = 'data/logs/langbot-%s.log' % time.strftime('%Y-%m-%d', time.localtime()) qcg_logger = logging.getLogger('langbot') qcg_logger.setLevel(level) color_formatter = colorlog.ColoredFormatter( fmt='%(log_color)s[%(asctime)s.%(msecs)03d] %(filename)s (%(lineno)d) - [%(levelname)s] : %(message)s', datefmt='%m-%d %H:%M:%S', log_colors=log_colors_config, ) stream_handler = logging.StreamHandler(sys.stdout) # stream_handler.setLevel(level) # stream_handler.setFormatter(color_formatter) stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1) # Use RotatingFileHandler to prevent unbounded log file growth rotating_file_handler = logging.handlers.RotatingFileHandler( log_file_name, encoding='utf-8', maxBytes=LOG_FILE_MAX_BYTES, backupCount=LOG_FILE_BACKUP_COUNT, ) log_handlers: list[logging.Handler] = [ stream_handler, rotating_file_handler, ] log_handlers += extra_handlers if extra_handlers is not None else [] for handler in log_handlers: handler.setLevel(level) handler.setFormatter(color_formatter) qcg_logger.addHandler(handler) qcg_logger.debug('Logging initialized, log level: %s' % level) logging.basicConfig( level=logging.CRITICAL, # Set log output format format='[DEPR][%(asctime)s.%(msecs)03d] %(pathname)s (%(lineno)d) - [%(levelname)s] :\n%(message)s', # Log output format # -8 is a placeholder, left-align the output, and output length is 8 datefmt='%Y-%m-%d %H:%M:%S', # Time output format handlers=[logging.NullHandler()], ) return qcg_logger ================================================ FILE: src/langbot/pkg/core/entities.py ================================================ from __future__ import annotations import enum class LifecycleControlScope(enum.Enum): APPLICATION = 'application' PLATFORM = 'platform' PLUGIN = 'plugin' PROVIDER = 'provider' ================================================ FILE: src/langbot/pkg/core/migration.py ================================================ from __future__ import annotations import abc import typing from . import app preregistered_migrations: list[typing.Type[Migration]] = [] """Currently not supported for extension""" def migration_class(name: str, number: int): """Register a migration""" def decorator(cls: typing.Type[Migration]) -> typing.Type[Migration]: cls.name = name cls.number = number preregistered_migrations.append(cls) return cls return decorator class Migration(abc.ABC): """A version migration""" name: str number: int ap: app.Application def __init__(self, ap: app.Application): self.ap = ap @abc.abstractmethod async def need_migrate(self) -> bool: """Determine if the current environment needs to run this migration""" pass @abc.abstractmethod async def run(self): """Run migration""" pass ================================================ FILE: src/langbot/pkg/core/migrations/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/core/migrations/m001_sensitive_word_migration.py ================================================ from __future__ import annotations import os from .. import migration @migration.migration_class('sensitive-word-migration', 1) class SensitiveWordMigration(migration.Migration): """敏感词迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return os.path.exists('data/config/sensitive-words.json') and not os.path.exists( 'data/metadata/sensitive-words.json' ) async def run(self): """执行迁移""" # 移动文件 os.rename('data/config/sensitive-words.json', 'data/metadata/sensitive-words.json') # 重新加载配置 await self.ap.sensitive_meta.load_config() ================================================ FILE: src/langbot/pkg/core/migrations/m002_openai_config_migration.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('openai-config-migration', 2) class OpenAIConfigMigration(migration.Migration): """OpenAI配置迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'openai-config' in self.ap.provider_cfg.data async def run(self): """执行迁移""" old_openai_config = self.ap.provider_cfg.data['openai-config'].copy() if 'keys' not in self.ap.provider_cfg.data: self.ap.provider_cfg.data['keys'] = {} if 'openai' not in self.ap.provider_cfg.data['keys']: self.ap.provider_cfg.data['keys']['openai'] = [] self.ap.provider_cfg.data['keys']['openai'] = old_openai_config['api-keys'] self.ap.provider_cfg.data['model'] = old_openai_config['chat-completions-params']['model'] del old_openai_config['chat-completions-params']['model'] if 'requester' not in self.ap.provider_cfg.data: self.ap.provider_cfg.data['requester'] = {} if 'openai-chat-completions' not in self.ap.provider_cfg.data['requester']: self.ap.provider_cfg.data['requester']['openai-chat-completions'] = {} self.ap.provider_cfg.data['requester']['openai-chat-completions'] = { 'base-url': old_openai_config['base_url'], 'args': old_openai_config['chat-completions-params'], 'timeout': old_openai_config['request-timeout'], } del self.ap.provider_cfg.data['openai-config'] await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m003_anthropic_requester_cfg_completion.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('anthropic-requester-config-completion', 3) class AnthropicRequesterConfigCompletionMigration(migration.Migration): """OpenAI配置迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return ( 'anthropic-messages' not in self.ap.provider_cfg.data['requester'] or 'anthropic' not in self.ap.provider_cfg.data['keys'] ) async def run(self): """执行迁移""" if 'anthropic-messages' not in self.ap.provider_cfg.data['requester']: self.ap.provider_cfg.data['requester']['anthropic-messages'] = { 'base-url': 'https://api.anthropic.com', 'args': {'max_tokens': 1024}, 'timeout': 120, } if 'anthropic' not in self.ap.provider_cfg.data['keys']: self.ap.provider_cfg.data['keys']['anthropic'] = [] await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m004_moonshot_cfg_completion.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('moonshot-config-completion', 4) class MoonshotConfigCompletionMigration(migration.Migration): """OpenAI配置迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return ( 'moonshot-chat-completions' not in self.ap.provider_cfg.data['requester'] or 'moonshot' not in self.ap.provider_cfg.data['keys'] ) async def run(self): """执行迁移""" if 'moonshot-chat-completions' not in self.ap.provider_cfg.data['requester']: self.ap.provider_cfg.data['requester']['moonshot-chat-completions'] = { 'base-url': 'https://api.moonshot.cn/v1', 'args': {}, 'timeout': 120, } if 'moonshot' not in self.ap.provider_cfg.data['keys']: self.ap.provider_cfg.data['keys']['moonshot'] = [] await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m005_deepseek_cfg_completion.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('deepseek-config-completion', 5) class DeepseekConfigCompletionMigration(migration.Migration): """OpenAI配置迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return ( 'deepseek-chat-completions' not in self.ap.provider_cfg.data['requester'] or 'deepseek' not in self.ap.provider_cfg.data['keys'] ) async def run(self): """执行迁移""" if 'deepseek-chat-completions' not in self.ap.provider_cfg.data['requester']: self.ap.provider_cfg.data['requester']['deepseek-chat-completions'] = { 'base-url': 'https://api.deepseek.com', 'args': {}, 'timeout': 120, } if 'deepseek' not in self.ap.provider_cfg.data['keys']: self.ap.provider_cfg.data['keys']['deepseek'] = [] await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m006_vision_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('vision-config', 6) class VisionConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'enable-vision' not in self.ap.provider_cfg.data async def run(self): """执行迁移""" if 'enable-vision' not in self.ap.provider_cfg.data: self.ap.provider_cfg.data['enable-vision'] = False await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m007_qcg_center_url.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('qcg-center-url-config', 7) class QCGCenterURLConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'qcg-center-url' not in self.ap.system_cfg.data async def run(self): """执行迁移""" if 'qcg-center-url' not in self.ap.system_cfg.data: self.ap.system_cfg.data['qcg-center-url'] = 'https://api.qchatgpt.rockchin.top/api/v2' await self.ap.system_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m008_ad_fixwin_config_migrate.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('ad-fixwin-cfg-migration', 8) class AdFixwinConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return isinstance(self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default'], int) async def run(self): """执行迁移""" for session_name in self.ap.pipeline_cfg.data['rate-limit']['fixwin']: temp_dict = { 'window-size': 60, 'limit': self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name], } self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name] = temp_dict await self.ap.pipeline_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m009_msg_truncator_cfg.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('msg-truncator-cfg-migration', 9) class MsgTruncatorConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'msg-truncate' not in self.ap.pipeline_cfg.data async def run(self): """执行迁移""" self.ap.pipeline_cfg.data['msg-truncate'] = { 'method': 'round', 'round': {'max-round': 10}, } await self.ap.pipeline_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m010_ollama_requester_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('ollama-requester-config', 10) class MsgTruncatorConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'ollama-chat' not in self.ap.provider_cfg.data['requester'] async def run(self): """执行迁移""" self.ap.provider_cfg.data['requester']['ollama-chat'] = { 'base-url': 'http://127.0.0.1:11434', 'args': {}, 'timeout': 600, } await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m011_command_prefix_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('command-prefix-config', 11) class CommandPrefixConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'command-prefix' not in self.ap.command_cfg.data async def run(self): """执行迁移""" self.ap.command_cfg.data['command-prefix'] = ['!', '!'] await self.ap.command_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m012_runner_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('runner-config', 12) class RunnerConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'runner' not in self.ap.provider_cfg.data async def run(self): """执行迁移""" self.ap.provider_cfg.data['runner'] = 'local-agent' await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m013_http_api_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('http-api-config', 13) class HttpApiConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'http-api' not in self.ap.system_cfg.data or 'persistence' not in self.ap.system_cfg.data async def run(self): """执行迁移""" self.ap.system_cfg.data['http-api'] = { 'enable': True, 'host': '0.0.0.0', 'port': 5300, 'jwt-expire': 604800, } self.ap.system_cfg.data['persistence'] = { 'sqlite': {'path': 'data/persistence.db'}, 'use': 'sqlite', } await self.ap.system_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m014_force_delay_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('force-delay-config', 14) class ForceDelayConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return isinstance(self.ap.platform_cfg.data['force-delay'], list) async def run(self): """执行迁移""" self.ap.platform_cfg.data['force-delay'] = { 'min': self.ap.platform_cfg.data['force-delay'][0], 'max': self.ap.platform_cfg.data['force-delay'][1], } await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m015_gitee_ai_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('gitee-ai-config', 15) class GiteeAIConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return ( 'gitee-ai-chat-completions' not in self.ap.provider_cfg.data['requester'] or 'gitee-ai' not in self.ap.provider_cfg.data['keys'] ) async def run(self): """执行迁移""" self.ap.provider_cfg.data['requester']['gitee-ai-chat-completions'] = { 'base-url': 'https://ai.gitee.com/v1', 'args': {}, 'timeout': 120, } self.ap.provider_cfg.data['keys']['gitee-ai'] = ['XXXXX'] await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m016_dify_service_api.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('dify-service-api-config', 16) class DifyServiceAPICfgMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'dify-service-api' not in self.ap.provider_cfg.data async def run(self): """执行迁移""" self.ap.provider_cfg.data['dify-service-api'] = { 'base-url': 'https://api.dify.ai/v1', 'app-type': 'chat', 'chat': {'api-key': 'app-1234567890'}, 'workflow': {'api-key': 'app-1234567890', 'output-key': 'summary'}, } await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m017_dify_api_timeout_params.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('dify-api-timeout-params', 17) class DifyAPITimeoutParamsMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return ( 'timeout' not in self.ap.provider_cfg.data['dify-service-api']['chat'] or 'timeout' not in self.ap.provider_cfg.data['dify-service-api']['workflow'] or 'agent' not in self.ap.provider_cfg.data['dify-service-api'] ) async def run(self): """执行迁移""" self.ap.provider_cfg.data['dify-service-api']['chat']['timeout'] = 120 self.ap.provider_cfg.data['dify-service-api']['workflow']['timeout'] = 120 self.ap.provider_cfg.data['dify-service-api']['agent'] = { 'api-key': 'app-1234567890', 'timeout': 120, } await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m018_xai_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('xai-config', 18) class XaiConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'xai-chat-completions' not in self.ap.provider_cfg.data['requester'] async def run(self): """执行迁移""" self.ap.provider_cfg.data['requester']['xai-chat-completions'] = { 'base-url': 'https://api.x.ai/v1', 'args': {}, 'timeout': 120, } self.ap.provider_cfg.data['keys']['xai'] = ['xai-1234567890'] await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m019_zhipuai_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('zhipuai-config', 19) class ZhipuaiConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'zhipuai-chat-completions' not in self.ap.provider_cfg.data['requester'] async def run(self): """执行迁移""" self.ap.provider_cfg.data['requester']['zhipuai-chat-completions'] = { 'base-url': 'https://open.bigmodel.cn/api/paas/v4', 'args': {}, 'timeout': 120, } self.ap.provider_cfg.data['keys']['zhipuai'] = ['xxxxxxx'] await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m020_wecom_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('wecom-config', 20) class WecomConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" # for adapter in self.ap.platform_cfg.data['platform-adapters']: # if adapter['adapter'] == 'wecom': # return False # return True return False async def run(self): """执行迁移""" self.ap.platform_cfg.data['platform-adapters'].append( { 'adapter': 'wecom', 'enable': False, 'host': '0.0.0.0', 'port': 2290, 'corpid': '', 'secret': '', 'token': '', 'EncodingAESKey': '', 'contacts_secret': '', } ) await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m021_lark_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('lark-config', 21) class LarkConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" # for adapter in self.ap.platform_cfg.data['platform-adapters']: # if adapter['adapter'] == 'lark': # return False # return True return False async def run(self): """执行迁移""" self.ap.platform_cfg.data['platform-adapters'].append( { 'adapter': 'lark', 'enable': False, 'app_id': 'cli_abcdefgh', 'app_secret': 'XXXXXXXXXX', 'bot_name': 'LangBot', 'enable-webhook': False, 'port': 2285, 'encrypt-key': 'xxxxxxxxx', } ) await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m022_lmstudio_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('lmstudio-config', 22) class LmStudioConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'lmstudio-chat-completions' not in self.ap.provider_cfg.data['requester'] async def run(self): """执行迁移""" self.ap.provider_cfg.data['requester']['lmstudio-chat-completions'] = { 'base-url': 'http://127.0.0.1:1234/v1', 'args': {}, 'timeout': 120, } await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m023_siliconflow_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('siliconflow-config', 23) class SiliconFlowConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'siliconflow-chat-completions' not in self.ap.provider_cfg.data['requester'] async def run(self): """执行迁移""" self.ap.provider_cfg.data['keys']['siliconflow'] = ['xxxxxxx'] self.ap.provider_cfg.data['requester']['siliconflow-chat-completions'] = { 'base-url': 'https://api.siliconflow.cn/v1', 'args': {}, 'timeout': 120, } await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m024_discord_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('discord-config', 24) class DiscordConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" # for adapter in self.ap.platform_cfg.data['platform-adapters']: # if adapter['adapter'] == 'discord': # return False # return True return False async def run(self): """执行迁移""" self.ap.platform_cfg.data['platform-adapters'].append( { 'adapter': 'discord', 'enable': False, 'client_id': '1234567890', 'token': 'XXXXXXXXXX', } ) await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m025_gewechat_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('gewechat-config', 25) class GewechatConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" # for adapter in self.ap.platform_cfg.data['platform-adapters']: # if adapter['adapter'] == 'gewechat': # return False # return True return False async def run(self): """执行迁移""" self.ap.platform_cfg.data['platform-adapters'].append( { 'adapter': 'gewechat', 'enable': False, 'gewechat_url': 'http://your-gewechat-server:2531', 'gewechat_file_url': 'http://your-gewechat-server:2532', 'port': 2286, 'callback_url': 'http://your-callback-url:2286/gewechat/callback', 'app_id': '', 'token': '', } ) await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m026_qqofficial_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('qqofficial-config', 26) class QQOfficialConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" # for adapter in self.ap.platform_cfg.data['platform-adapters']: # if adapter['adapter'] == 'qqofficial': # return False # return True return False async def run(self): """执行迁移""" self.ap.platform_cfg.data['platform-adapters'].append( { 'adapter': 'qqofficial', 'enable': False, 'appid': '', 'secret': '', 'port': 2284, 'token': '', } ) await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m027_wx_official_account_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('wx-official-account-config', 27) class WXOfficialAccountConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" # for adapter in self.ap.platform_cfg.data['platform-adapters']: # if adapter['adapter'] == 'officialaccount': # return False # return True return False async def run(self): """执行迁移""" self.ap.platform_cfg.data['platform-adapters'].append( { 'adapter': 'officialaccount', 'enable': False, 'token': '', 'EncodingAESKey': '', 'AppID': '', 'AppSecret': '', 'host': '0.0.0.0', 'port': 2287, } ) await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m028_aliyun_requester_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('bailian-requester-config', 28) class BailianRequesterConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'bailian-chat-completions' not in self.ap.provider_cfg.data['requester'] async def run(self): """执行迁移""" self.ap.provider_cfg.data['keys']['bailian'] = ['sk-xxxxxxx'] self.ap.provider_cfg.data['requester']['bailian-chat-completions'] = { 'base-url': 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'args': {}, 'timeout': 120, } await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m029_dashscope_app_api_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('dashscope-app-api-config', 29) class DashscopeAppAPICfgMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'dashscope-app-api' not in self.ap.provider_cfg.data async def run(self): """执行迁移""" self.ap.provider_cfg.data['dashscope-app-api'] = { 'app-type': 'agent', 'api-key': 'sk-1234567890', 'agent': {'app-id': 'Your_app_id', 'references_quote': '参考资料来自:'}, 'workflow': { 'app-id': 'Your_app_id', 'references_quote': '参考资料来自:', 'biz_params': {'city': '北京', 'date': '2023-08-10'}, }, } await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m030_lark_config_cmpl.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('lark-config-cmpl', 30) class LarkConfigCmplMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" for adapter in self.ap.platform_cfg.data['platform-adapters']: if adapter['adapter'] == 'lark': if 'enable-webhook' not in adapter: return True return False async def run(self): """执行迁移""" for adapter in self.ap.platform_cfg.data['platform-adapters']: if adapter['adapter'] == 'lark': if 'enable-webhook' not in adapter: adapter['enable-webhook'] = False if 'port' not in adapter: adapter['port'] = 2285 if 'encrypt-key' not in adapter: adapter['encrypt-key'] = 'xxxxxxxxx' await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m031_dingtalk_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('dingtalk-config', 31) class DingTalkConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" # for adapter in self.ap.platform_cfg.data['platform-adapters']: # if adapter['adapter'] == 'dingtalk': # return False # return True return False async def run(self): """执行迁移""" self.ap.platform_cfg.data['platform-adapters'].append( { 'adapter': 'dingtalk', 'enable': False, 'client_id': '', 'client_secret': '', 'robot_code': '', 'robot_name': '', } ) await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m032_volcark_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('volcark-requester-config', 32) class VolcArkRequesterConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'volcark-chat-completions' not in self.ap.provider_cfg.data['requester'] async def run(self): """执行迁移""" self.ap.provider_cfg.data['keys']['volcark'] = ['xxxxxxxx'] self.ap.provider_cfg.data['requester']['volcark-chat-completions'] = { 'base-url': 'https://ark.cn-beijing.volces.com/api/v3', 'args': {}, 'timeout': 120, } await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m033_dify_thinking_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('dify-thinking-config', 33) class DifyThinkingConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" if 'options' not in self.ap.provider_cfg.data['dify-service-api']: return True if 'convert-thinking-tips' not in self.ap.provider_cfg.data['dify-service-api']['options']: return True return False async def run(self): """执行迁移""" self.ap.provider_cfg.data['dify-service-api']['options'] = {'convert-thinking-tips': 'plain'} await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m034_gewechat_file_url_config.py ================================================ from __future__ import annotations from urllib.parse import urlparse from .. import migration @migration.migration_class('gewechat-file-url-config', 34) class GewechatFileUrlConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" for adapter in self.ap.platform_cfg.data['platform-adapters']: if adapter['adapter'] == 'gewechat': if 'gewechat_file_url' not in adapter: return True return False async def run(self): """执行迁移""" for adapter in self.ap.platform_cfg.data['platform-adapters']: if adapter['adapter'] == 'gewechat': if 'gewechat_file_url' not in adapter: parsed_url = urlparse(adapter['gewechat_url']) adapter['gewechat_file_url'] = f'{parsed_url.scheme}://{parsed_url.hostname}:2532' await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m035_wxoa_mode.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('wxoa-mode', 35) class WxoaModeMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" for adapter in self.ap.platform_cfg.data['platform-adapters']: if adapter['adapter'] == 'officialaccount': if 'Mode' not in adapter: return True return False async def run(self): """执行迁移""" for adapter in self.ap.platform_cfg.data['platform-adapters']: if adapter['adapter'] == 'officialaccount': if 'Mode' not in adapter: adapter['Mode'] = 'drop' await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m036_wxoa_loading_message.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('wxoa-loading-message', 36) class WxoaLoadingMessageMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" for adapter in self.ap.platform_cfg.data['platform-adapters']: if adapter['adapter'] == 'officialaccount': if 'LoadingMessage' not in adapter: return True return False async def run(self): """执行迁移""" for adapter in self.ap.platform_cfg.data['platform-adapters']: if adapter['adapter'] == 'officialaccount': if 'LoadingMessage' not in adapter: adapter['LoadingMessage'] = 'AI正在思考中,请发送任意内容获取回复。' await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m037_mcp_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('mcp-config', 37) class MCPConfigMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return 'mcp' not in self.ap.provider_cfg.data async def run(self): """执行迁移""" self.ap.provider_cfg.data['mcp'] = {'servers': []} await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m038_tg_dingtalk_markdown.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('tg-dingtalk-markdown', 38) class TgDingtalkMarkdownMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" for adapter in self.ap.platform_cfg.data['platform-adapters']: if adapter['adapter'] in ['dingtalk', 'telegram']: if 'markdown_card' not in adapter: return True return False async def run(self): """执行迁移""" for adapter in self.ap.platform_cfg.data['platform-adapters']: if adapter['adapter'] in ['dingtalk', 'telegram']: if 'markdown_card' not in adapter: adapter['markdown_card'] = False await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m039_modelscope_cfg_completion.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('modelscope-config-completion', 39) class ModelScopeConfigCompletionMigration(migration.Migration): """ModelScope配置迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return ( 'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester'] or 'modelscope' not in self.ap.provider_cfg.data['keys'] ) async def run(self): """执行迁移""" if 'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester']: self.ap.provider_cfg.data['requester']['modelscope-chat-completions'] = { 'base-url': 'https://api-inference.modelscope.cn/v1', 'args': {}, 'timeout': 120, } if 'modelscope' not in self.ap.provider_cfg.data['keys']: self.ap.provider_cfg.data['keys']['modelscope'] = [] await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m040_ppio_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('ppio-config', 40) class PPIOConfigMigration(migration.Migration): """PPIO配置迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return ( 'ppio-chat-completions' not in self.ap.provider_cfg.data['requester'] or 'ppio' not in self.ap.provider_cfg.data['keys'] ) async def run(self): """执行迁移""" if 'ppio-chat-completions' not in self.ap.provider_cfg.data['requester']: self.ap.provider_cfg.data['requester']['ppio-chat-completions'] = { 'base-url': 'https://api.ppinfra.com/v3/openai', 'args': {}, 'timeout': 120, } if 'ppio' not in self.ap.provider_cfg.data['keys']: self.ap.provider_cfg.data['keys']['ppio'] = [] await self.ap.provider_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/migrations/m041_dingtalk_card_autolayout_config.py ================================================ from __future__ import annotations from .. import migration @migration.migration_class('dingtalk_card_auto_layout', 41) class DingTalkCardAutoLayoutMigration(migration.Migration): """迁移""" async def need_migrate(self) -> bool: """判断当前环境是否需要运行此迁移""" return True async def run(self): """执行迁移""" self.ap.platform_cfg.data['platform-adapters']['app']['dingtalk']['card_auto_layout'] = False await self.ap.platform_cfg.dump_config() ================================================ FILE: src/langbot/pkg/core/note.py ================================================ from __future__ import annotations import abc import typing from . import app preregistered_notes: list[typing.Type[LaunchNote]] = [] def note_class(name: str, number: int): """Register a launch information""" def decorator(cls: typing.Type[LaunchNote]) -> typing.Type[LaunchNote]: cls.name = name cls.number = number preregistered_notes.append(cls) return cls return decorator class LaunchNote(abc.ABC): """Launch information""" name: str number: int ap: app.Application def __init__(self, ap: app.Application): self.ap = ap @abc.abstractmethod async def need_show(self) -> bool: """Determine if the current environment needs to display this launch information""" pass @abc.abstractmethod async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]: """Generate launch information""" pass ================================================ FILE: src/langbot/pkg/core/notes/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/core/notes/n001_classic_msgs.py ================================================ from __future__ import annotations import typing from .. import note @note.note_class('ClassicNotes', 1) class ClassicNotes(note.LaunchNote): """Classic launch information""" async def need_show(self) -> bool: return True async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]: yield await self.ap.ver_mgr.show_version_update() ================================================ FILE: src/langbot/pkg/core/notes/n002_selection_mode_on_windows.py ================================================ from __future__ import annotations import typing import os import logging from .. import note @note.note_class('SelectionModeOnWindows', 2) class SelectionModeOnWindows(note.LaunchNote): """Selection mode prompt information on Windows""" async def need_show(self) -> bool: return os.name == 'nt' async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]: yield ( """您正在使用 Windows 系统,若窗口左上角显示处于”选择“模式,程序将被暂停运行,此时请右键窗口中空白区域退出选择模式。""", logging.INFO, ) yield ( """You are using Windows system, if the top left corner of the window displays "Selection" mode, the program will be paused running, please right-click on the blank area in the window to exit the selection mode.""", logging.INFO, ) ================================================ FILE: src/langbot/pkg/core/notes/n003_print_version.py ================================================ from __future__ import annotations import typing import logging from .. import note @note.note_class('PrintVersion', 3) class PrintVersion(note.LaunchNote): """Print Version Information""" async def need_show(self) -> bool: return True async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]: yield f'Current Version: {self.ap.ver_mgr.get_current_version()}', logging.INFO ================================================ FILE: src/langbot/pkg/core/stage.py ================================================ from __future__ import annotations import abc import typing from . import app preregistered_stages: dict[str, typing.Type[BootingStage]] = {} """Pre-registered request processing stages. All request processing stage classes are registered in this dictionary during initialization. Currently not supported for extension """ def stage_class(name: str): def decorator(cls: typing.Type[BootingStage]) -> typing.Type[BootingStage]: preregistered_stages[name] = cls return cls return decorator class BootingStage(abc.ABC): """Booting stage""" name: str = None @abc.abstractmethod async def run(self, ap: app.Application): """Run""" pass ================================================ FILE: src/langbot/pkg/core/stages/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/core/stages/build_app.py ================================================ from __future__ import annotations import asyncio from .. import stage, app from ...utils import version, proxy from ...pipeline import pool, controller, pipelinemgr from ...pipeline import aggregator as message_aggregator from ...plugin import connector as plugin_connector from ...command import cmdmgr from ...provider.session import sessionmgr as llm_session_mgr from ...provider.modelmgr import modelmgr as llm_model_mgr from ...provider.tools import toolmgr as llm_tool_mgr from ...rag.knowledge import kbmgr as rag_mgr from ...rag.service import RAGRuntimeService from ...platform import botmgr as im_mgr from ...platform.webhook_pusher import WebhookPusher from ...persistence import mgr as persistencemgr from ...api.http.controller import main as http_controller from ...api.http.service import user as user_service from ...api.http.service import space as space_service from ...api.http.service import model as model_service from ...api.http.service import provider as provider_service from ...api.http.service import pipeline as pipeline_service from ...api.http.service import bot as bot_service from ...api.http.service import knowledge as knowledge_service from ...api.http.service import mcp as mcp_service from ...api.http.service import apikey as apikey_service from ...api.http.service import webhook as webhook_service from ...api.http.service import monitoring as monitoring_service from ...discover import engine as discover_engine from ...storage import mgr as storagemgr from ...utils import logcache from ...vector import mgr as vectordb_mgr from .. import taskmgr from ...telemetry import telemetry as telemetry_module from ...survey import manager as survey_module @stage.stage_class('BuildAppStage') class BuildAppStage(stage.BootingStage): """Build LangBot application""" async def run(self, ap: app.Application): """Build LangBot application""" ap.task_mgr = taskmgr.AsyncTaskManager(ap) discover = discover_engine.ComponentDiscoveryEngine(ap) discover.discover_blueprint('templates/components.yaml') ap.discover = discover user_service_inst = user_service.UserService(ap) ap.user_service = user_service_inst space_service_inst = space_service.SpaceService(ap) ap.space_service = space_service_inst llm_model_service_inst = model_service.LLMModelsService(ap) ap.llm_model_service = llm_model_service_inst embedding_models_service_inst = model_service.EmbeddingModelsService(ap) ap.embedding_models_service = embedding_models_service_inst provider_service_inst = provider_service.ModelProviderService(ap) ap.provider_service = provider_service_inst pipeline_service_inst = pipeline_service.PipelineService(ap) ap.pipeline_service = pipeline_service_inst bot_service_inst = bot_service.BotService(ap) ap.bot_service = bot_service_inst knowledge_service_inst = knowledge_service.KnowledgeService(ap) ap.knowledge_service = knowledge_service_inst mcp_service_inst = mcp_service.MCPService(ap) ap.mcp_service = mcp_service_inst apikey_service_inst = apikey_service.ApiKeyService(ap) ap.apikey_service = apikey_service_inst webhook_service_inst = webhook_service.WebhookService(ap) ap.webhook_service = webhook_service_inst proxy_mgr = proxy.ProxyManager(ap) await proxy_mgr.initialize() ap.proxy_mgr = proxy_mgr ver_mgr = version.VersionManager(ap) await ver_mgr.initialize() ap.ver_mgr = ver_mgr ap.query_pool = pool.QueryPool() log_cache = logcache.LogCache() ap.log_cache = log_cache storage_mgr_inst = storagemgr.StorageMgr(ap) await storage_mgr_inst.initialize() ap.storage_mgr = storage_mgr_inst persistence_mgr_inst = persistencemgr.PersistenceManager(ap) ap.persistence_mgr = persistence_mgr_inst await persistence_mgr_inst.initialize() # Telemetry manager: attach to app so other components can call via self.ap.telemetry telemetry_inst = telemetry_module.TelemetryManager(ap) await telemetry_inst.initialize() ap.telemetry = telemetry_inst # Survey manager survey_inst = survey_module.SurveyManager(ap) await survey_inst.initialize() ap.survey = survey_inst cmd_mgr_inst = cmdmgr.CommandManager(ap) await cmd_mgr_inst.initialize() ap.cmd_mgr = cmd_mgr_inst llm_model_mgr_inst = llm_model_mgr.ModelManager(ap) ap.model_mgr = llm_model_mgr_inst await llm_model_mgr_inst.initialize() llm_session_mgr_inst = llm_session_mgr.SessionManager(ap) await llm_session_mgr_inst.initialize() ap.sess_mgr = llm_session_mgr_inst llm_tool_mgr_inst = llm_tool_mgr.ToolManager(ap) await llm_tool_mgr_inst.initialize() ap.tool_mgr = llm_tool_mgr_inst im_mgr_inst = im_mgr.PlatformManager(ap=ap) await im_mgr_inst.initialize() ap.platform_mgr = im_mgr_inst # Initialize webhook pusher webhook_pusher_inst = WebhookPusher(ap) ap.webhook_pusher = webhook_pusher_inst pipeline_mgr = pipelinemgr.PipelineManager(ap) await pipeline_mgr.initialize() ap.pipeline_mgr = pipeline_mgr # Initialize message aggregator (after pipeline_mgr, as it needs pipeline config) msg_aggregator_inst = message_aggregator.MessageAggregator(ap) ap.msg_aggregator = msg_aggregator_inst rag_mgr_inst = rag_mgr.RAGManager(ap) await rag_mgr_inst.initialize() ap.rag_mgr = rag_mgr_inst # Initialize RAG Runtime Service for plugins ap.rag_runtime_service = RAGRuntimeService(ap) # 初始化向量数据库管理器 vectordb_mgr_inst = vectordb_mgr.VectorDBManager(ap) await vectordb_mgr_inst.initialize() ap.vector_db_mgr = vectordb_mgr_inst http_ctrl = http_controller.HTTPController(ap) await http_ctrl.initialize() ap.http_ctrl = http_ctrl monitoring_service_inst = monitoring_service.MonitoringService(ap) ap.monitoring_service = monitoring_service_inst async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None: await asyncio.sleep(3) await plugin_connector_inst.initialize() plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback) await plugin_connector_inst.initialize() ap.plugin_connector = plugin_connector_inst ctrl = controller.Controller(ap) ap.ctrl = ctrl ================================================ FILE: src/langbot/pkg/core/stages/genkeys.py ================================================ from __future__ import annotations import secrets from .. import stage, app @stage.stage_class('GenKeysStage') class GenKeysStage(stage.BootingStage): """Generate keys stage""" async def run(self, ap: app.Application): """Generate keys""" if not ap.instance_config.data['system']['jwt']['secret']: ap.instance_config.data['system']['jwt']['secret'] = secrets.token_hex(16) await ap.instance_config.dump_config() if 'recovery_key' not in ap.instance_config.data['system']: ap.instance_config.data['system']['recovery_key'] = '' if not ap.instance_config.data['system']['recovery_key']: ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper() await ap.instance_config.dump_config() ================================================ FILE: src/langbot/pkg/core/stages/load_config.py ================================================ from __future__ import annotations import os from typing import Any from langbot.pkg.utils import constants import yaml import importlib.resources as resources import uuid import time from .. import stage, app from ..bootutils import config def _apply_env_overrides_to_config(cfg: dict) -> dict: """Apply environment variable overrides to data/config.yaml Environment variables should be uppercase and use __ (double underscore) to represent nested keys. For example: - CONCURRENCY__PIPELINE overrides concurrency.pipeline - PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url Arrays and dict types are ignored. Args: cfg: Configuration dictionary Returns: Updated configuration dictionary """ def convert_value(value: str, original_value: Any) -> Any: """Convert string value to appropriate type based on original value Args: value: String value from environment variable original_value: Original value to infer type from Returns: Converted value (falls back to string if conversion fails) """ if isinstance(original_value, bool): return value.lower() in ('true', '1', 'yes', 'on') elif isinstance(original_value, int): try: return int(value) except ValueError: # If conversion fails, keep as string (user error, but non-breaking) return value elif isinstance(original_value, float): try: return float(value) except ValueError: # If conversion fails, keep as string (user error, but non-breaking) return value else: return value # Process environment variables for env_key, env_value in os.environ.items(): # Check if the environment variable is uppercase and contains __ if not env_key.isupper(): continue if '__' not in env_key: continue print(f'apply env overrides to config: env_key: {env_key}, env_value: {env_value}') # Convert environment variable name to config path # e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline'] keys = [key.lower() for key in env_key.split('__')] # Navigate to the target value and validate the path current = cfg for i, key in enumerate(keys): if not isinstance(current, dict): break if i == len(keys) - 1: # At the final key if key in current: if isinstance(current[key], (dict, list)): # Skip dict and list types pass else: # Valid scalar value - convert and set it converted_value = convert_value(env_value, current[key]) current[key] = converted_value else: # Key doesn't exist yet - create it as string current[key] = env_value else: # Navigate deeper - create intermediate dict if needed if key not in current: current[key] = {} current = current[key] return cfg @stage.stage_class('LoadConfigStage') class LoadConfigStage(stage.BootingStage): """Load config file stage""" async def run(self, ap: app.Application): """Load config file""" # # ======= deprecated ======= # if os.path.exists('data/config/command.json'): # ap.command_cfg = await config.load_json_config( # 'data/config/command.json', # 'templates/legacy/command.json', # completion=False, # ) # if os.path.exists('data/config/pipeline.json'): # ap.pipeline_cfg = await config.load_json_config( # 'data/config/pipeline.json', # 'templates/legacy/pipeline.json', # completion=False, # ) # if os.path.exists('data/config/platform.json'): # ap.platform_cfg = await config.load_json_config( # 'data/config/platform.json', # 'templates/legacy/platform.json', # completion=False, # ) # if os.path.exists('data/config/provider.json'): # ap.provider_cfg = await config.load_json_config( # 'data/config/provider.json', # 'templates/legacy/provider.json', # completion=False, # ) # if os.path.exists('data/config/system.json'): # ap.system_cfg = await config.load_json_config( # 'data/config/system.json', # 'templates/legacy/system.json', # completion=False, # ) # # ======= deprecated ======= ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False) # Apply environment variable overrides to data/config.yaml ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data) await ap.instance_config.dump_config() # load or generate instance id # Priority: # 1. system.instance_id from config.yaml (can be set via SYSTEM__INSTANCE_ID env var) # 2. data/labels/instance_id.json (if file exists) # 3. Generate new and save to file config_instance_id = ap.instance_config.data.get('system', {}).get('instance_id', '') if config_instance_id: # Use the instance_id from config.yaml constants.instance_id = config_instance_id # Still load/create the file for backward compat, but don't use its value ap.instance_id = await config.load_json_config( 'data/labels/instance_id.json', template_data={ 'instance_id': f'instance_{str(uuid.uuid4())}', 'instance_create_ts': int(time.time()), }, completion=False, ) else: # Try loading file-based instance id instance_id_path = os.path.join('data', 'labels', 'instance_id.json') if os.path.exists(instance_id_path): # File exists, read it ap.instance_id = await config.load_json_config( 'data/labels/instance_id.json', template_data={ 'instance_id': '', 'instance_create_ts': 0, }, completion=False, ) constants.instance_id = ap.instance_id.data['instance_id'] else: # Neither config nor file, generate new and save to file new_id = f'instance_{str(uuid.uuid4())}' ap.instance_id = await config.load_json_config( 'data/labels/instance_id.json', template_data={ 'instance_id': new_id, 'instance_create_ts': int(time.time()), }, completion=False, ) constants.instance_id = new_id constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community') print(f'LangBot instance id: {constants.instance_id}') print(f'LangBot edition: {constants.edition}') await ap.instance_id.dump_config() ap.sensitive_meta = await config.load_json_config( 'data/metadata/sensitive-words.json', 'metadata/sensitive-words.json', ) await ap.sensitive_meta.dump_config() async def load_resource_yaml_template_data(resource_name: str) -> dict: with resources.files('langbot.templates').joinpath(resource_name).open('r', encoding='utf-8') as f: return yaml.load(f, Loader=yaml.FullLoader) ap.pipeline_config_meta_trigger = await load_resource_yaml_template_data('metadata/pipeline/trigger.yaml') ap.pipeline_config_meta_safety = await load_resource_yaml_template_data('metadata/pipeline/safety.yaml') ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml') ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml') ================================================ FILE: src/langbot/pkg/core/stages/migrate.py ================================================ from __future__ import annotations from .. import stage, app from .. import migration from ...utils import importutil from .. import migrations importutil.import_modules_in_pkg(migrations) @stage.stage_class('MigrationStage') class MigrationStage(stage.BootingStage): """Migration stage These migrations are legacy, only performed in version 3.x """ async def run(self, ap: app.Application): """Run migration""" if any( [ ap.command_cfg is None, ap.pipeline_cfg is None, ap.platform_cfg is None, ap.provider_cfg is None, ap.system_cfg is None, ] ): # only run migration when version is 3.x return migrations = migration.preregistered_migrations # Sort by migration number migrations.sort(key=lambda x: x.number) for migration_cls in migrations: migration_instance = migration_cls(ap) if await migration_instance.need_migrate(): await migration_instance.run() print(f'Migration {migration_instance.name} executed') ================================================ FILE: src/langbot/pkg/core/stages/setup_logger.py ================================================ from __future__ import annotations import logging from .. import stage, app from ..bootutils import log class PersistenceHandler(logging.Handler, object): """ Save logs to database """ ap: app.Application def __init__(self, name, ap: app.Application): logging.Handler.__init__(self) self.ap = ap def emit(self, record): """ emit function is a required function for custom handler classes, here you can process the log messages as needed, such as sending logs to the server Emit a record """ try: msg = self.format(record) if self.ap.log_cache is not None: self.ap.log_cache.add_log(msg) except Exception: self.handleError(record) @stage.stage_class('SetupLoggerStage') class SetupLoggerStage(stage.BootingStage): """Setup logger stage""" async def run(self, ap: app.Application): """Setup logger""" persistence_handler = PersistenceHandler('LoggerHandler', ap) extra_handlers = [] extra_handlers = [persistence_handler] ap.logger = await log.init_logging(extra_handlers) ================================================ FILE: src/langbot/pkg/core/stages/show_notes.py ================================================ from __future__ import annotations import asyncio from .. import stage, app, note from ...utils import importutil from .. import notes importutil.import_modules_in_pkg(notes) @stage.stage_class('ShowNotesStage') class ShowNotesStage(stage.BootingStage): """Show notes stage""" async def run(self, ap: app.Application): # Sort note.preregistered_notes.sort(key=lambda x: x.number) for note_cls in note.preregistered_notes: try: note_inst = note_cls(ap) if await note_inst.need_show(): async def ayield_note(note_inst: note.LaunchNote): async for ret in note_inst.yield_note(): if not ret: continue msg, level = ret if msg: ap.logger.log(level, msg) asyncio.create_task(ayield_note(note_inst)) except Exception: continue ================================================ FILE: src/langbot/pkg/core/taskmgr.py ================================================ from __future__ import annotations import asyncio import typing import datetime from . import app from . import entities as core_entities class TaskContext: """Task tracking context""" current_action: str """Current action being executed""" log: str """Log""" def __init__(self): self.current_action = 'default' self.log = '' def _log(self, msg: str): self.log += msg + '\n' def set_current_action(self, action: str): self.current_action = action def trace( self, msg: str, action: str = None, ): if action is not None: self.set_current_action(action) self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}') def to_dict(self) -> dict: return {'current_action': self.current_action, 'log': self.log} @staticmethod def new() -> TaskContext: return TaskContext() @staticmethod def placeholder() -> TaskContext: global placeholder_context if placeholder_context is None: placeholder_context = TaskContext() return placeholder_context placeholder_context: TaskContext | None = None class TaskWrapper: """Task wrapper""" _id_index: int = 0 """Task ID index""" id: int """Task ID""" task_type: str = 'system' # Task type: system or user """Task type""" kind: str = 'system_task' # Task type determined by the initiator, usually the same task type """Task type""" name: str = '' """Task unique name""" label: str = '' """Task display name""" task_context: TaskContext """Task context""" task: asyncio.Task """Task""" task_stack: list = None """Task stack""" ap: app.Application """Application instance""" scopes: list[core_entities.LifecycleControlScope] """Task scope""" def __init__( self, ap: app.Application, coro: typing.Coroutine, task_type: str = 'system', kind: str = 'system_task', name: str = '', label: str = '', context: TaskContext = None, scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION], ): self.id = TaskWrapper._id_index TaskWrapper._id_index += 1 self.ap = ap self.task_context = context or TaskContext() self.task = self.ap.event_loop.create_task(coro) self.task_type = task_type self.kind = kind self.name = name self.label = label if label != '' else name self.task.set_name(name) self.scopes = scopes def assume_exception(self): try: exception = self.task.exception() if self.task_stack is None: self.task_stack = self.task.get_stack() return exception except Exception: return None def assume_result(self): try: return self.task.result() except Exception: return None def to_dict(self) -> dict: exception_traceback = None if self.assume_exception() is not None: exception_traceback = 'Traceback (most recent call last):\n' for frame in self.task_stack: exception_traceback += ( f' File "{frame.f_code.co_filename}", line {frame.f_lineno}, in {frame.f_code.co_name}\n' ) exception_traceback += f' {self.assume_exception().__str__()}\n' return { 'id': self.id, 'task_type': self.task_type, 'kind': self.kind, 'name': self.name, 'label': self.label, 'scopes': [scope.value for scope in self.scopes], 'task_context': self.task_context.to_dict(), 'runtime': { 'done': self.task.done(), 'state': self.task._state, 'exception': self.assume_exception().__str__() if self.assume_exception() is not None else None, 'exception_traceback': exception_traceback, 'result': self.assume_result() if self.assume_result() is not None else None, }, } def cancel(self): self.task.cancel() class AsyncTaskManager: """Save all asynchronous tasks in the app Include system-level and user-level (plugin installation, update, etc. initiated by users directly)""" ap: app.Application tasks: list[TaskWrapper] """All tasks""" def __init__(self, ap: app.Application): self.ap = ap self.tasks = [] def create_task( self, coro: typing.Coroutine, task_type: str = 'system', kind: str = 'system-task', name: str = '', label: str = '', context: TaskContext = None, scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION], ) -> TaskWrapper: wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes) self.tasks.append(wrapper) return wrapper def create_user_task( self, coro: typing.Coroutine, kind: str = 'user-task', name: str = '', label: str = '', context: TaskContext = None, scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION], ) -> TaskWrapper: return self.create_task(coro, 'user', kind, name, label, context, scopes) async def wait_all(self): await asyncio.gather(*[t.task for t in self.tasks], return_exceptions=True) def get_all_tasks(self) -> list[TaskWrapper]: return self.tasks def get_tasks_dict( self, type: str = None, ) -> dict: return { 'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type], 'id_index': TaskWrapper._id_index, } def get_task_by_id(self, id: int) -> TaskWrapper | None: for t in self.tasks: if t.id == id: return t return None def cancel_by_scope(self, scope: core_entities.LifecycleControlScope): for wrapper in self.tasks: if not wrapper.task.done() and scope in wrapper.scopes: wrapper.task.cancel() def cancel_task(self, task_id: int): for wrapper in self.tasks: if wrapper.id == task_id: if not wrapper.task.done(): wrapper.task.cancel() return ================================================ FILE: src/langbot/pkg/discover/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/discover/engine.py ================================================ from __future__ import annotations import typing import importlib import os import yaml import pydantic from langbot.pkg.core import app from langbot.pkg.utils import importutil class I18nString(pydantic.BaseModel): """国际化字符串""" en_US: str """英文""" zh_Hans: typing.Optional[str] = None """中文""" ja_JP: typing.Optional[str] = None """日文""" def to_dict(self) -> dict: """转换为字典""" dic = {} if self.en_US is not None: dic['en_US'] = self.en_US if self.zh_Hans is not None: dic['zh_Hans'] = self.zh_Hans if self.ja_JP is not None: dic['ja_JP'] = self.ja_JP return dic class Metadata(pydantic.BaseModel): """元数据""" name: str """名称""" label: I18nString """标签""" description: typing.Optional[I18nString] = None """描述""" version: typing.Optional[str] = None """版本""" icon: typing.Optional[str] = None """图标""" author: typing.Optional[str] = None """作者""" repository: typing.Optional[str] = None """仓库""" def __init__(self, **kwargs): super().__init__(**kwargs) if self.description is None: self.description = I18nString(en_US='') if self.icon is None: self.icon = '' class PythonExecution(pydantic.BaseModel): """Python执行""" path: str """路径""" attr: str """属性""" def __init__(self, **kwargs): super().__init__(**kwargs) if self.path.startswith('./'): self.path = self.path[2:] class Execution(pydantic.BaseModel): """执行""" python: PythonExecution """Python执行""" class Component(pydantic.BaseModel): """组件清单""" owner: str """组件所属""" manifest: typing.Dict[str, typing.Any] """组件清单内容""" rel_path: str """组件清单相对main.py的路径""" rel_dir: str """组件清单相对main.py的目录""" _metadata: Metadata """组件元数据""" _spec: typing.Dict[str, typing.Any] """组件规格""" _execution: Execution """组件执行""" def __init__(self, owner: str, manifest: typing.Dict[str, typing.Any], rel_path: str): super().__init__( owner=owner, manifest=manifest, rel_path=rel_path, rel_dir=os.path.dirname(rel_path), ) self._metadata = Metadata(**manifest['metadata']) self._spec = manifest['spec'] self._execution = Execution(**manifest['execution']) if 'execution' in manifest else None @classmethod def is_component_manifest(cls, manifest: typing.Dict[str, typing.Any]) -> bool: """判断是否为组件清单""" return 'apiVersion' in manifest and 'kind' in manifest and 'metadata' in manifest and 'spec' in manifest @property def kind(self) -> str: """组件类型""" return self.manifest['kind'] @property def metadata(self) -> Metadata: """组件元数据""" return self._metadata @property def spec(self) -> typing.Dict[str, typing.Any]: """组件规格""" return self._spec @property def execution(self) -> Execution: """组件可执行文件信息""" return self._execution @property def icon_rel_path(self) -> str: """图标相对路径""" return ( os.path.join(self.rel_dir, self.metadata.icon) if self.metadata.icon is not None and self.metadata.icon.strip() != '' else None ) def get_python_component_class(self) -> typing.Type[typing.Any]: """获取Python组件类""" module_path = os.path.join(self.rel_dir, self.execution.python.path) if module_path.endswith('.py'): module_path = module_path[:-3] module_path = module_path.replace('/', '.').replace('\\', '.') module = importlib.import_module(f'langbot.{module_path}') return getattr(module, self.execution.python.attr) def to_plain_dict(self) -> dict: """转换为平铺字典""" return { 'name': self.metadata.name, 'label': self.metadata.label.to_dict(), 'description': self.metadata.description.to_dict(), 'icon': self.metadata.icon, 'spec': self.spec, } class ComponentDiscoveryEngine: """组件发现引擎""" ap: app.Application """应用实例""" components: typing.Dict[str, typing.List[Component]] = {} """组件列表""" def __init__(self, ap: app.Application): self.ap = ap def load_component_manifest(self, path: str, owner: str = 'builtin', no_save: bool = False) -> Component | None: """加载组件清单""" # with open(path, 'r', encoding='utf-8') as f: # manifest = yaml.safe_load(f) manifest = yaml.safe_load(importutil.read_resource_file(path)) if not Component.is_component_manifest(manifest): return None comp = Component(owner=owner, manifest=manifest, rel_path=path) if not no_save: if comp.kind not in self.components: self.components[comp.kind] = [] self.components[comp.kind].append(comp) return comp def load_component_manifests_in_dir( self, path: str, owner: str = 'builtin', no_save: bool = False, max_depth: int = 1, ) -> typing.List[Component]: """加载目录中的组件清单""" components: typing.List[Component] = [] def recursive_load_component_manifests_in_dir(path: str, depth: int = 1): if depth > max_depth: return for file in importutil.list_resource_files(path): if (not os.path.isdir(os.path.join(path, file))) and (file.endswith('.yaml') or file.endswith('.yml')): comp = self.load_component_manifest(os.path.join(path, file), owner, no_save) if comp is not None: components.append(comp) elif os.path.isdir(os.path.join(path, file)): recursive_load_component_manifests_in_dir(os.path.join(path, file), depth + 1) recursive_load_component_manifests_in_dir(path) return components def load_blueprint_comp_group( self, group: dict, owner: str = 'builtin', no_save: bool = False ) -> typing.List[Component]: """加载蓝图组件组""" components: typing.List[Component] = [] if 'fromFiles' in group: for file in group['fromFiles']: comp = self.load_component_manifest(file, owner, no_save) if comp is not None: components.append(comp) if 'fromDirs' in group: for dir in group['fromDirs']: path = dir['path'] max_depth = dir['maxDepth'] if 'maxDepth' in dir else 1 components.extend(self.load_component_manifests_in_dir(path, owner, no_save, max_depth)) return components def discover_blueprint(self, blueprint_manifest_path: str, owner: str = 'builtin'): """发现蓝图""" blueprint_manifest = self.load_component_manifest(blueprint_manifest_path, owner, no_save=True) if blueprint_manifest is None: raise ValueError(f'Invalid blueprint manifest: {blueprint_manifest_path}') assert blueprint_manifest.kind == 'Blueprint', '`Kind` must be `Blueprint`' components: typing.Dict[str, typing.List[Component]] = {} # load ComponentTemplate first if 'ComponentTemplate' in blueprint_manifest.spec['components']: components['ComponentTemplate'] = self.load_blueprint_comp_group( blueprint_manifest.spec['components']['ComponentTemplate'], owner ) for name, component in blueprint_manifest.spec['components'].items(): if name == 'ComponentTemplate': continue components[name] = self.load_blueprint_comp_group(component, owner) self.ap.logger.debug(f'Components: {components}') return blueprint_manifest, components def get_components_by_kind(self, kind: str) -> typing.List[Component]: """获取指定类型的组件""" if kind not in self.components: return [] return self.components[kind] def find_components(self, kind: str, component_list: typing.List[Component]) -> typing.List[Component]: """查找组件""" result: typing.List[Component] = [] for component in component_list: if component.kind == kind: result.append(component) return result ================================================ FILE: src/langbot/pkg/entity/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/entity/dto/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/entity/dto/space_model.py ================================================ # [ # { # "uuid": "7652ebdb-54dc-412c-a830-e9268ac88471", # "model_id": "claude-opus-4-5-20251101", # "display_name": { # "en_US": "claude-opus-4-5-20251101", # "zh_Hans": "claude-opus-4-5-20251101" # }, # "description": {}, # "provider": "anthropic", # "category": "chat", # "icon_url": "Claude.Color", # "tags": {}, # "is_featured": true, # "featured_order": 999, # "model_ratio": 2.5, # "completion_ratio": 5, # "quota_type": 0, # "model_price": 0, # "input_credits": 500, # "output_credits": 2500, # "vendor_id": 1, # "vendor_name": "Anthropic", # "vendor_icon": "Claude.Color", # "supported_endpoints": [ # "anthropic", # "openai" # ], # "status": "active", # "metadata": null, # "created_at": "2025-12-30T22:23:38.337207+08:00", # "updated_at": "2025-12-30T22:23:38.337207+08:00" # } # ] import pydantic class SpaceModel(pydantic.BaseModel): uuid: str model_id: str provider: str category: str # chat / embedding llm_abilities: list[str] | None = None is_featured: bool = False featured_order: int = 0 status: str created_at: str | None = None updated_at: str | None = None ================================================ FILE: src/langbot/pkg/entity/errors/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/entity/errors/account.py ================================================ from __future__ import annotations class AccountEmailMismatchError(Exception): def __str__(self): return 'Account email mismatch' ================================================ FILE: src/langbot/pkg/entity/errors/platform.py ================================================ from __future__ import annotations class AdapterNotFoundError(Exception): def __init__(self, adapter_name: str): self.adapter_name = adapter_name def __str__(self): return f'Adapter {self.adapter_name} not found' ================================================ FILE: src/langbot/pkg/entity/errors/provider.py ================================================ from __future__ import annotations class RequesterNotFoundError(Exception): def __init__(self, requester_name: str): self.requester_name = requester_name def __str__(self): return f'Requester {self.requester_name} not found' class ProviderNotFoundError(Exception): def __init__(self, provider_name: str): self.provider_name = provider_name def __str__(self): return f'Provider {self.provider_name} not found' ================================================ FILE: src/langbot/pkg/entity/persistence/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/entity/persistence/apikey.py ================================================ import sqlalchemy from .base import Base class ApiKey(Base): """API Key for external service authentication""" __tablename__ = 'api_keys' id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True) description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='') created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) ================================================ FILE: src/langbot/pkg/entity/persistence/base.py ================================================ import sqlalchemy.orm class Base(sqlalchemy.orm.DeclarativeBase): pass ================================================ FILE: src/langbot/pkg/entity/persistence/bot.py ================================================ import sqlalchemy from .base import Base class Bot(Base): """Bot""" __tablename__ = 'bots' uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) adapter = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) adapter_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) ================================================ FILE: src/langbot/pkg/entity/persistence/bstorage.py ================================================ import sqlalchemy from .base import Base class BinaryStorage(Base): """Current for plugin use only""" __tablename__ = 'binary_storages' unique_key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) owner_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) owner = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) value = sqlalchemy.Column(sqlalchemy.LargeBinary, nullable=False) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) ================================================ FILE: src/langbot/pkg/entity/persistence/mcp.py ================================================ import sqlalchemy from .base import Base class MCPServer(Base): __tablename__ = 'mcp_servers' uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) ================================================ FILE: src/langbot/pkg/entity/persistence/metadata.py ================================================ import sqlalchemy from .base import Base from ...utils import constants initial_metadata = [ { 'key': 'database_version', 'value': str(constants.required_database_version), }, ] class Metadata(Base): """Database metadata""" __tablename__ = 'metadata' key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) value = sqlalchemy.Column(sqlalchemy.String(255)) ================================================ FILE: src/langbot/pkg/entity/persistence/model.py ================================================ import sqlalchemy from .base import Base class ModelProvider(Base): """Model provider""" __tablename__ = 'model_providers' uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) base_url = sqlalchemy.Column(sqlalchemy.String(512), nullable=False) api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[]) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) class LLMModel(Base): """LLM model""" __tablename__ = 'llm_models' uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[]) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) class EmbeddingModel(Base): """Embedding model""" __tablename__ = 'embedding_models' uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) ================================================ FILE: src/langbot/pkg/entity/persistence/monitoring.py ================================================ import sqlalchemy from .base import Base class MonitoringMessage(Base): """Monitoring message records""" __tablename__ = 'monitoring_messages' id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) message_content = sqlalchemy.Column(sqlalchemy.Text, nullable=False) session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error, pending level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant class MonitoringLLMCall(Base): """LLM call records""" __tablename__ = 'monitoring_llm_calls' id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) input_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) output_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) total_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds cost = sqlalchemy.Column(sqlalchemy.Float, nullable=True) status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True) message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID class MonitoringSession(Base): """Session tracking records""" __tablename__ = 'monitoring_sessions' session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) message_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) last_activity = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True) platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name class MonitoringError(Base): """Error log records""" __tablename__ = 'monitoring_errors' id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) error_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=False) bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) stack_trace = sqlalchemy.Column(sqlalchemy.Text, nullable=True) message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID class MonitoringEmbeddingCall(Base): """Embedding call records""" __tablename__ = 'monitoring_embedding_calls' id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) prompt_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) total_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds input_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # Number of input texts status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Optional context fields knowledge_base_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) query_text = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # For retrieval calls session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve ================================================ FILE: src/langbot/pkg/entity/persistence/pipeline.py ================================================ import sqlalchemy from .base import Base class LegacyPipeline(Base): """Legacy pipeline""" __tablename__ = 'legacy_pipelines' uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='⚙️') created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) for_version = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) is_default = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) stages = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) extensions_preferences = sqlalchemy.Column( sqlalchemy.JSON, nullable=False, default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []}, ) class PipelineRunRecord(Base): """Pipeline run record""" __tablename__ = 'pipeline_run_records' uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) status = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) started_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False) finished_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False) result = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) knowledge_base_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) ================================================ FILE: src/langbot/pkg/entity/persistence/plugin.py ================================================ import sqlalchemy from .base import Base class PluginSetting(Base): """Plugin setting""" __tablename__ = 'plugin_settings' plugin_author = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) plugin_name = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True) priority = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict) install_source = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, default='github') install_info = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) ================================================ FILE: src/langbot/pkg/entity/persistence/rag.py ================================================ import sqlalchemy from .base import Base class KnowledgeBase(Base): __tablename__ = 'knowledge_bases' uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) name = sqlalchemy.Column(sqlalchemy.String, index=True) description = sqlalchemy.Column(sqlalchemy.Text) emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='📚') created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now()) # New fields for plugin-based RAG knowledge_engine_plugin_id = sqlalchemy.Column(sqlalchemy.String, nullable=True) collection_id = sqlalchemy.Column(sqlalchemy.String, nullable=True) creation_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None) retrieval_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None) # Field sets for different operations MUTABLE_FIELDS = {'name', 'description', 'retrieval_settings'} """Fields that can be updated after creation.""" CREATE_FIELDS = MUTABLE_FIELDS | {'uuid', 'knowledge_engine_plugin_id', 'collection_id', 'creation_settings'} """Fields used when creating a new knowledge base.""" ALL_DB_FIELDS = CREATE_FIELDS | {'emoji', 'created_at', 'updated_at'} """All fields stored in database (for loading from DB row).""" class File(Base): __tablename__ = 'knowledge_base_files' uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) kb_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) file_name = sqlalchemy.Column(sqlalchemy.String) extension = sqlalchemy.Column(sqlalchemy.String) created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now()) status = sqlalchemy.Column(sqlalchemy.String, default='pending') # pending, processing, completed, failed class Chunk(Base): __tablename__ = 'knowledge_base_chunks' uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) file_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) text = sqlalchemy.Column(sqlalchemy.Text) ================================================ FILE: src/langbot/pkg/entity/persistence/user.py ================================================ import sqlalchemy from .base import Base class User(Base): __tablename__ = 'users' id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # Account type: 'local' (default) or 'space' account_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local') # Space account fields (nullable, only used when account_type='space') space_account_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) space_access_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True) space_refresh_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True) space_access_token_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) space_api_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) ================================================ FILE: src/langbot/pkg/entity/persistence/vector.py ================================================ from sqlalchemy import Column, Integer, ForeignKey, LargeBinary from sqlalchemy.orm import declarative_base, relationship Base = declarative_base() class Vector(Base): __tablename__ = 'vectors' id = Column(Integer, primary_key=True, index=True) chunk_id = Column(Integer, ForeignKey('chunks.id'), unique=True) embedding = Column(LargeBinary) # Store embeddings as binary chunk = relationship('Chunk', back_populates='vector') ================================================ FILE: src/langbot/pkg/entity/persistence/webhook.py ================================================ import sqlalchemy from .base import Base class Webhook(Base): """Webhook for pushing bot events to external systems""" __tablename__ = 'webhooks' id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) url = sqlalchemy.Column(sqlalchemy.String(1024), nullable=False) description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='') enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(), ) ================================================ FILE: src/langbot/pkg/persistence/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/persistence/database.py ================================================ from __future__ import annotations import abc import sqlalchemy.ext.asyncio as sqlalchemy_asyncio from ..core import app preregistered_managers: list[type[BaseDatabaseManager]] = [] def manager_class(name: str) -> None: """Register a database manager class""" def decorator(cls: type[BaseDatabaseManager]) -> type[BaseDatabaseManager]: cls.name = name preregistered_managers.append(cls) return cls return decorator class BaseDatabaseManager(abc.ABC): """Base database manager class""" name: str ap: app.Application engine: sqlalchemy_asyncio.AsyncEngine def __init__(self, ap: app.Application) -> None: self.ap = ap @abc.abstractmethod async def initialize(self) -> None: pass def get_engine(self) -> sqlalchemy_asyncio.AsyncEngine: return self.engine ================================================ FILE: src/langbot/pkg/persistence/databases/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/persistence/databases/postgresql.py ================================================ from __future__ import annotations import sqlalchemy.ext.asyncio as sqlalchemy_asyncio from .. import database @database.manager_class('postgresql') class PostgreSQLDatabaseManager(database.BaseDatabaseManager): """PostgreSQL database manager""" async def initialize(self) -> None: postgresql_config = self.ap.instance_config.data.get('database', {}).get('postgresql', {}) host = postgresql_config.get('host', '127.0.0.1') port = postgresql_config.get('port', 5432) user = postgresql_config.get('user', 'postgres') password = postgresql_config.get('password', 'postgres') database = postgresql_config.get('database', 'postgres') engine_url = f'postgresql+asyncpg://{user}:{password}@{host}:{port}/{database}' self.engine = sqlalchemy_asyncio.create_async_engine(engine_url) ================================================ FILE: src/langbot/pkg/persistence/databases/sqlite.py ================================================ from __future__ import annotations import sqlalchemy.ext.asyncio as sqlalchemy_asyncio from .. import database @database.manager_class('sqlite') class SQLiteDatabaseManager(database.BaseDatabaseManager): """SQLite database manager""" async def initialize(self) -> None: db_file_path = self.ap.instance_config.data.get('database', {}).get('sqlite', {}).get('path', 'data/langbot.db') engine_url = f'sqlite+aiosqlite:///{db_file_path}' self.engine = sqlalchemy_asyncio.create_async_engine(engine_url) ================================================ FILE: src/langbot/pkg/persistence/mgr.py ================================================ from __future__ import annotations import datetime import typing import json import uuid import sqlalchemy.ext.asyncio as sqlalchemy_asyncio import sqlalchemy from . import database, migration from ..entity.persistence import base, pipeline, metadata, model as persistence_model from ..entity import persistence from ..core import app from ..utils import constants, importutil from ..api.http.service import pipeline as pipeline_service from . import databases, migrations importutil.import_modules_in_pkg(databases) importutil.import_modules_in_pkg(migrations) importutil.import_modules_in_pkg(persistence) class PersistenceManager: """Persistence module manager""" ap: app.Application db: database.BaseDatabaseManager """Database manager""" meta: sqlalchemy.MetaData def __init__(self, ap: app.Application): self.ap = ap self.meta = base.Base.metadata async def initialize(self): database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite') self.ap.logger.info(f'Initializing database type: {database_type}...') for manager in database.preregistered_managers: if manager.name == database_type: self.db = manager(self.ap) await self.db.initialize() break await self.create_tables() # run migrations database_version = await self.execute_async( sqlalchemy.select(metadata.Metadata).where(metadata.Metadata.key == 'database_version') ) database_version = int(database_version.fetchone()[1]) required_database_version = constants.required_database_version if database_version < required_database_version: migrations = migration.preregistered_db_migrations migrations.sort(key=lambda x: x.number) last_migration_number = database_version for migration_cls in migrations: migration_instance = migration_cls(self.ap) if ( migration_instance.number > database_version and migration_instance.number <= required_database_version ): await migration_instance.upgrade() await self.execute_async( sqlalchemy.update(metadata.Metadata) .where(metadata.Metadata.key == 'database_version') .values({'value': str(migration_instance.number)}) ) last_migration_number = migration_instance.number self.ap.logger.info(f'Migration {migration_instance.number} completed.') self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.') await self.write_default_pipeline() await self.write_space_model_providers() async def create_tables(self): # create tables async with self.get_db_engine().connect() as conn: await conn.run_sync(self.meta.create_all) await conn.commit() # ======= write initial data ======= # write initial metadata self.ap.logger.info('Creating initial metadata...') for item in metadata.initial_metadata: # check if the item exists result = await self.execute_async( sqlalchemy.select(metadata.Metadata).where(metadata.Metadata.key == item['key']) ) row = result.first() if row is None: await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item)) async def write_default_pipeline(self): # write default pipeline result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline)) default_pipeline_uuid = None if result.first() is None: self.ap.logger.info('Creating default pipeline...') pipeline_config = json.loads(importutil.read_resource_file('templates/default-pipeline-config.json')) default_pipeline_uuid = str(uuid.uuid4()) pipeline_data = { 'uuid': default_pipeline_uuid, 'for_version': self.ap.ver_mgr.get_current_version(), 'stages': pipeline_service.default_stage_order, 'is_default': True, 'name': 'ChatPipeline', 'description': 'Default pipeline, new bots will be bound to this pipeline | 默认提供的流水线,您配置的机器人将自动绑定到此流水线', 'config': pipeline_config, 'extensions_preferences': {}, } await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data)) async def write_space_model_providers(self): space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get( 'models_gateway_api_url', 'https://api.langbot.cloud/v1' ) # write space model providers result = await self.execute_async( sqlalchemy.select(persistence_model.ModelProvider).where( persistence_model.ModelProvider.requester == 'space-chat-completions' ) ) exists_space_chat_completions_model_provider = result.first() # api keys will be set/updated when the oauth callback if exists_space_chat_completions_model_provider is None: self.ap.logger.info('Creating space model providers...') space_chat_completions_model_provider = { 'uuid': '00000000-0000-0000-0000-000000000000', 'name': 'LangBot Models', 'requester': 'space-chat-completions', 'base_url': space_models_gateway_api_url, 'api_keys': [], } await self.execute_async( sqlalchemy.insert(persistence_model.ModelProvider).values(space_chat_completions_model_provider) ) else: if exists_space_chat_completions_model_provider.base_url != space_models_gateway_api_url: await self.execute_async( sqlalchemy.update(persistence_model.ModelProvider) .where(persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid) .values({'base_url': space_models_gateway_api_url}) ) # ================================= async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult: async with self.get_db_engine().connect() as conn: result = await conn.execute(*args, **kwargs) await conn.commit() return result def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine: return self.db.get_engine() def serialize_model( self, model: typing.Type[sqlalchemy.Base], data: sqlalchemy.Base, masked_columns: list[str] = [] ) -> dict: return { column.name: getattr(data, column.name) if not isinstance(getattr(data, column.name), (datetime.datetime)) else getattr(data, column.name).isoformat() for column in model.__table__.columns if column.name not in masked_columns } ================================================ FILE: src/langbot/pkg/persistence/migration.py ================================================ from __future__ import annotations import typing import abc from ..core import app preregistered_db_migrations: list[typing.Type[DBMigration]] = [] def migration_class(number: int): """Migration class decorator""" def wrapper(cls: typing.Type[DBMigration]) -> typing.Type[DBMigration]: cls.number = number preregistered_db_migrations.append(cls) return cls return wrapper class DBMigration(abc.ABC): """Database migration""" number: int """Migration number""" def __init__(self, ap: app.Application): self.ap = ap @abc.abstractmethod async def upgrade(self): """Upgrade""" pass @abc.abstractmethod async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py ================================================ from .. import migration from copy import deepcopy import uuid import os import sqlalchemy import shutil from ...config import manager as config_manager from ...entity.persistence import ( model as persistence_model, pipeline as persistence_pipeline, bot as persistence_bot, ) @migration.migration_class(1) class DBMigrateV3Config(migration.DBMigration): """Migrate v3 config to v4 database""" async def upgrade(self): """Upgrade""" """ Migrate all config files under data/config. After migration, all previous config files are saved under data/legacy/config. After migration, all config files under data/metadata/ are saved under data/legacy/metadata. """ if self.ap.provider_cfg is None: return # ======= Migrate model ======= # Only migrate the currently selected model model_name = self.ap.provider_cfg.data.get('model', 'gpt-4o') model_requester = 'openai-chat-completions' model_requester_config = {} model_api_keys = ['sk-proj-1234567890'] model_abilities = [] model_extra_args = {} if os.path.exists('data/metadata/llm-models.json'): _llm_model_meta = await config_manager.load_json_config('data/metadata/llm-models.json', completion=False) for item in _llm_model_meta.data.get('list', []): if item.get('name') == model_name: if 'model_name' in item: model_name = item['model_name'] if 'requester' in item: model_requester = item['requester'] if 'token_mgr' in item: _token_mgr = item['token_mgr'] if _token_mgr in self.ap.provider_cfg.data.get('keys', {}): model_api_keys = self.ap.provider_cfg.data.get('keys', {})[_token_mgr] if 'tool_call_supported' in item and item['tool_call_supported']: model_abilities.append('func_call') if 'vision_supported' in item and item['vision_supported']: model_abilities.append('vision') if ( model_requester in self.ap.provider_cfg.data.get('requester', {}) and 'args' in self.ap.provider_cfg.data.get('requester', {})[model_requester] ): model_extra_args = self.ap.provider_cfg.data.get('requester', {})[model_requester]['args'] if model_requester in self.ap.provider_cfg.data.get('requester', {}): model_requester_config = self.ap.provider_cfg.data.get('requester', {})[model_requester] model_requester_config = { 'base_url': model_requester_config['base-url'], 'timeout': model_requester_config['timeout'], } break model_uuid = str(uuid.uuid4()) llm_model_data = { 'uuid': model_uuid, 'name': model_name, 'description': '由 LangBot v3 迁移而来', 'requester': model_requester, 'requester_config': model_requester_config, 'api_keys': model_api_keys, 'abilities': model_abilities, 'extra_args': model_extra_args, } await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_model.LLMModel).values(**llm_model_data) ) # ======= Migrate pipeline config ======= # Modify to default pipeline default_pipeline = [ self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) for pipeline in ( await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( persistence_pipeline.LegacyPipeline.is_default == True ) ) ).all() ][0] pipeline_uuid = str(uuid.uuid4()) pipeline_name = 'ChatPipeline' if default_pipeline: pipeline_name = default_pipeline['name'] pipeline_uuid = default_pipeline['uuid'] pipeline_config = default_pipeline['config'] # ai pipeline_config['ai']['runner'] = { 'runner': self.ap.provider_cfg.data['runner'], } pipeline_config['ai']['local-agent']['model'] = model_uuid pipeline_config['ai']['local-agent']['max-round'] = self.ap.pipeline_cfg.data['msg-truncate']['round'][ 'max-round' ] pipeline_config['ai']['local-agent']['prompt'] = [ { 'role': 'system', 'content': self.ap.provider_cfg.data['prompt']['default'], } ] pipeline_config['ai']['dify-service-api'] = { 'base-url': self.ap.provider_cfg.data['dify-service-api']['base-url'], 'app-type': self.ap.provider_cfg.data['dify-service-api']['app-type'], 'api-key': self.ap.provider_cfg.data['dify-service-api'][ self.ap.provider_cfg.data['dify-service-api']['app-type'] ]['api-key'], 'thinking-convert': self.ap.provider_cfg.data['dify-service-api']['options']['convert-thinking-tips'], 'timeout': self.ap.provider_cfg.data['dify-service-api'][ self.ap.provider_cfg.data['dify-service-api']['app-type'] ]['timeout'], } pipeline_config['ai']['dashscope-app-api'] = { 'app-type': self.ap.provider_cfg.data['dashscope-app-api']['app-type'], 'api-key': self.ap.provider_cfg.data['dashscope-app-api']['api-key'], 'references_quote': self.ap.provider_cfg.data['dashscope-app-api'][ self.ap.provider_cfg.data['dashscope-app-api']['app-type'] ]['references_quote'], } # trigger pipeline_config['trigger']['group-respond-rules'] = self.ap.pipeline_cfg.data['respond-rules']['default'] pipeline_config['trigger']['access-control'] = self.ap.pipeline_cfg.data['access-control'] pipeline_config['trigger']['ignore-rules'] = self.ap.pipeline_cfg.data['ignore-rules'] # safety pipeline_config['safety']['content-filter'] = { 'scope': 'all', 'check-sensitive-words': self.ap.pipeline_cfg.data['check-sensitive-words'], } pipeline_config['safety']['rate-limit'] = { 'window-length': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['window-size'], 'limitation': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['limit'], 'strategy': self.ap.pipeline_cfg.data['rate-limit']['strategy'], } # output pipeline_config['output']['long-text-processing'] = self.ap.platform_cfg.data['long-text-process'] pipeline_config['output']['force-delay'] = self.ap.platform_cfg.data['force-delay'] pipeline_config['output']['misc'] = { 'hide-exception': self.ap.platform_cfg.data['hide-exception-info'], 'quote-origin': self.ap.platform_cfg.data['quote-origin'], 'at-sender': self.ap.platform_cfg.data['at-sender'], 'track-function-calls': self.ap.platform_cfg.data['track-function-calls'], } default_pipeline['description'] = default_pipeline['description'] + ' [已迁移 LangBot v3 配置]' default_pipeline['config'] = pipeline_config default_pipeline.pop('created_at') default_pipeline.pop('updated_at') await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_pipeline.LegacyPipeline) .values(default_pipeline) .where(persistence_pipeline.LegacyPipeline.uuid == default_pipeline['uuid']) ) # ======= Migrate bot ======= # Only migrate enabled bots for adapter in self.ap.platform_cfg.data.get('platform-adapters', []): if not adapter.get('enable'): continue args = deepcopy(adapter) args.pop('adapter') args.pop('enable') bot_data = { 'uuid': str(uuid.uuid4()), 'name': adapter.get('adapter'), 'description': '由 LangBot v3 迁移而来', 'adapter': adapter.get('adapter'), 'adapter_config': args, 'enable': True, 'use_pipeline_uuid': pipeline_uuid, 'use_pipeline_name': pipeline_name, } await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(**bot_data)) # ======= Migrate system settings ======= self.ap.instance_config.data['admins'] = self.ap.system_cfg.data['admin-sessions'] self.ap.instance_config.data['api']['port'] = self.ap.system_cfg.data['http-api']['port'] self.ap.instance_config.data['command'] = { 'prefix': self.ap.command_cfg.data['command-prefix'], 'enable': self.ap.command_cfg.data['command-enable'] if 'command-enable' in self.ap.command_cfg.data else True, 'privilege': self.ap.command_cfg.data['privilege'], } self.ap.instance_config.data['concurrency']['pipeline'] = self.ap.system_cfg.data['pipeline-concurrency'] self.ap.instance_config.data['concurrency']['session'] = self.ap.system_cfg.data['session-concurrency'][ 'default' ] self.ap.instance_config.data['mcp'] = self.ap.provider_cfg.data['mcp'] self.ap.instance_config.data['proxy'] = self.ap.system_cfg.data['network-proxies'] await self.ap.instance_config.dump_config() # ======= move files ======= # Migrate all config files under data/config all_legacy_dir_name = [ 'config', # 'metadata', 'prompts', 'scenario', ] def move_legacy_files(dir_name: str): if not os.path.exists(f'data/legacy/{dir_name}'): os.makedirs(f'data/legacy/{dir_name}') if os.path.exists(f'data/{dir_name}'): for file in os.listdir(f'data/{dir_name}'): if file.endswith('.json'): shutil.move(f'data/{dir_name}/{file}', f'data/legacy/{dir_name}/{file}') os.rmdir(f'data/{dir_name}') for dir_name in all_legacy_dir_name: move_legacy_files(dir_name) async def downgrade(self): """Downgrade""" ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py ================================================ from .. import migration import sqlalchemy import json @migration.migration_class(2) class DBMigrateCombineQuoteMsgConfig(migration.DBMigration): """Combine quote message config""" async def upgrade(self): """Upgrade""" # Read all pipelines using raw SQL result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] # Ensure 'trigger' exists if 'trigger' not in config: config['trigger'] = {} # Ensure 'misc' exists in 'trigger' if 'misc' not in config['trigger']: config['trigger']['misc'] = {} # Add 'combine-quote-message' if not exists if 'combine-quote-message' not in config['trigger']['misc']: config['trigger']['misc']['combine-quote-message'] = False # Update using raw SQL with compatibility for both SQLite and PostgreSQL if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py ================================================ from .. import migration import sqlalchemy import json @migration.migration_class(3) class DBMigrateN8nConfig(migration.DBMigration): """N8n config""" async def upgrade(self): """Upgrade""" # Read all pipelines using raw SQL result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] # Ensure 'ai' exists if 'ai' not in config: config['ai'] = {} # Add 'n8n-service-api' if not exists if 'n8n-service-api' not in config['ai']: config['ai']['n8n-service-api'] = { 'webhook-url': 'http://your-n8n-webhook-url', 'auth-type': 'none', 'basic-username': '', 'basic-password': '', 'jwt-secret': '', 'jwt-algorithm': 'HS256', 'header-name': '', 'header-value': '', 'timeout': 120, 'output-key': 'response', } # Update using raw SQL with compatibility for both SQLite and PostgreSQL if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py ================================================ from .. import migration import sqlalchemy import json @migration.migration_class(4) class DBMigrateRAGKBUUID(migration.DBMigration): """RAG知识库UUID""" async def upgrade(self): """升级""" # Read all pipelines using raw SQL result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] # Ensure nested structure exists if 'ai' not in config: config['ai'] = {} if 'local-agent' not in config['ai']: config['ai']['local-agent'] = {} # Add 'knowledge-base' if not exists if 'knowledge-base' not in config['ai']['local-agent']: config['ai']['local-agent']['knowledge-base'] = '' # Update using raw SQL with compatibility for both SQLite and PostgreSQL if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) async def downgrade(self): """降级""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py ================================================ from .. import migration import sqlalchemy import json @migration.migration_class(5) class DBMigratePipelineRemoveCotConfig(migration.DBMigration): """Pipeline remove cot config""" async def upgrade(self): """Upgrade""" # Read all pipelines using raw SQL result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] # Ensure nested structure exists if 'output' not in config: config['output'] = {} if 'misc' not in config['output']: config['output']['misc'] = {} # Add 'remove-think' if not exists if 'remove-think' not in config['output']['misc']: config['output']['misc']['remove-think'] = False # Update using raw SQL with compatibility for both SQLite and PostgreSQL if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py ================================================ from .. import migration import sqlalchemy import json @migration.migration_class(6) class DBMigrateLangflowApiConfig(migration.DBMigration): """Langflow API config""" async def upgrade(self): """Upgrade""" # Read all pipelines using raw SQL result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] # Ensure 'ai' exists if 'ai' not in config: config['ai'] = {} # Add 'langflow-api' if not exists if 'langflow-api' not in config['ai']: config['ai']['langflow-api'] = { 'base-url': 'http://localhost:7860', 'api-key': 'your-api-key', 'flow-id': 'your-flow-id', 'input-type': 'chat', 'output-type': 'chat', 'tweaks': '{}', } # Update using raw SQL with compatibility for both SQLite and PostgreSQL if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py ================================================ import sqlalchemy from .. import migration @migration.migration_class(7) class DBMigratePluginInstallSource(migration.DBMigration): """插件安装来源""" async def upgrade(self): """升级""" # 查询表结构获取所有列名(异步执行 SQL) columns = [] if self.ap.persistence_mgr.db.name == 'postgresql': result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text( "SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_settings';" ) ) all_result = result.fetchall() columns = [row[0] for row in all_result] else: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(plugin_settings);')) all_result = result.fetchall() columns = [row[1] for row in all_result] # 检查并添加 install_source 列 if 'install_source' not in columns: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( "ALTER TABLE plugin_settings ADD COLUMN install_source VARCHAR(255) NOT NULL DEFAULT 'github'" ) ) # 检查并添加 install_info 列 if 'install_info' not in columns: await self.ap.persistence_mgr.execute_async( sqlalchemy.text("ALTER TABLE plugin_settings ADD COLUMN install_info JSON NOT NULL DEFAULT '{}'") ) async def downgrade(self): """降级""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py ================================================ from .. import migration @migration.migration_class(8) class DBMigratePluginConfig(migration.DBMigration): """插件配置""" async def upgrade(self): """升级""" if 'plugin' not in self.ap.instance_config.data: self.ap.instance_config.data['plugin'] = { 'runtime_ws_url': 'ws://langbot_plugin_runtime:5400/control/ws', 'enable_marketplace': True, 'cloud_service_url': 'https://space.langbot.app', } await self.ap.instance_config.dump_config() async def downgrade(self): """降级""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py ================================================ import sqlalchemy from .. import migration @migration.migration_class(9) class DBMigratePipelineExtensionPreferences(migration.DBMigration): """Pipeline extension preferences""" async def upgrade(self): """Upgrade""" sql_text = sqlalchemy.text( "ALTER TABLE legacy_pipelines ADD COLUMN extensions_preferences JSON NOT NULL DEFAULT '{}'" ) await self.ap.persistence_mgr.execute_async(sql_text) async def downgrade(self): """Downgrade""" sql_text = sqlalchemy.text('ALTER TABLE legacy_pipelines DROP COLUMN extensions_preferences') await self.ap.persistence_mgr.execute_async(sql_text) ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py ================================================ from .. import migration import sqlalchemy import json @migration.migration_class(10) class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration): """Pipeline support multiple knowledge base binding""" async def upgrade(self): """Upgrade""" # Read all pipelines using raw SQL result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] # Convert knowledge-base from string to array if 'ai' in config and 'local-agent' in config['ai']: current_kb = config['ai']['local-agent'].get('knowledge-base', '') # If it's already a list, skip if isinstance(current_kb, list): continue # Convert string to list if current_kb and current_kb != '__none__': config['ai']['local-agent']['knowledge-bases'] = [current_kb] else: config['ai']['local-agent']['knowledge-bases'] = [] # Remove old field if 'knowledge-base' in config['ai']['local-agent']: del config['ai']['local-agent']['knowledge-base'] # Update using raw SQL with compatibility for both SQLite and PostgreSQL if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) async def downgrade(self): """Downgrade""" # Read all pipelines using raw SQL result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] # Convert knowledge-bases from array back to string if 'ai' in config and 'local-agent' in config['ai']: current_kbs = config['ai']['local-agent'].get('knowledge-bases', []) # If it's already a string, skip if isinstance(current_kbs, str): continue # Convert list to string (take first one or empty) if current_kbs and len(current_kbs) > 0: config['ai']['local-agent']['knowledge-base'] = current_kbs[0] else: config['ai']['local-agent']['knowledge-base'] = '' # Remove new field if 'knowledge-bases' in config['ai']['local-agent']: del config['ai']['local-agent']['knowledge-bases'] # Update using raw SQL with compatibility for both SQLite and PostgreSQL if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py ================================================ from .. import migration import sqlalchemy import json @migration.migration_class(11) class DBMigrateDifyApiConfig(migration.DBMigration): """Dify base prompt config""" async def upgrade(self): """Upgrade""" # Read all pipelines using raw SQL result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] # Ensure nested structure exists if 'ai' not in config: config['ai'] = {} if 'dify-service-api' not in config['ai']: config['ai']['dify-service-api'] = {} # Add 'base-prompt' if not exists if 'base-prompt' not in config['ai']['dify-service-api']: config['ai']['dify-service-api']['base-prompt'] = ( 'When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.', ) # Update using raw SQL with compatibility for both SQLite and PostgreSQL if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py ================================================ from .. import migration import sqlalchemy import json @migration.migration_class(12) class DBMigratePipelineExtensionsEnableAll(migration.DBMigration): """Pipeline extensions enable all""" async def upgrade(self): """Upgrade""" # Read all pipelines using raw SQL result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, extensions_preferences FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] extensions_preferences = ( json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] ) # Ensure extensions_preferences is a dict if extensions_preferences is None: extensions_preferences = {} # Add 'enable_all_plugins' if not exists if 'enable_all_plugins' not in extensions_preferences: if 'plugins' in extensions_preferences: extensions_preferences['enable_all_plugins'] = False else: extensions_preferences['enable_all_plugins'] = True extensions_preferences['plugins'] = [] # Add 'enable_all_mcp_servers' if not exists if 'enable_all_mcp_servers' not in extensions_preferences: if 'mcp_servers' in extensions_preferences: extensions_preferences['enable_all_mcp_servers'] = False else: extensions_preferences['enable_all_mcp_servers'] = True extensions_preferences['mcp_servers'] = [] # Update using raw SQL with compatibility for both SQLite and PostgreSQL if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences::jsonb, for_version = :for_version WHERE uuid = :uuid' ), { 'extensions_preferences': json.dumps(extensions_preferences), 'for_version': current_version, 'uuid': uuid, }, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences, for_version = :for_version WHERE uuid = :uuid' ), { 'extensions_preferences': json.dumps(extensions_preferences), 'for_version': current_version, 'uuid': uuid, }, ) async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py ================================================ import sqlalchemy from .. import migration @migration.migration_class(13) class DBMigrateKnowledgeBaseUpdatedAt(migration.DBMigration): """Add updated_at field to knowledge_bases table""" async def upgrade(self): """Upgrade""" # Get all column names from the table columns = [] if self.ap.persistence_mgr.db.name == 'postgresql': result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text( "SELECT column_name FROM information_schema.columns WHERE table_name = 'knowledge_bases';" ) ) all_result = result.fetchall() columns = [row[0] for row in all_result] else: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(knowledge_bases);')) all_result = result.fetchall() columns = [row[1] for row in all_result] # Check and add updated_at column if 'updated_at' not in columns: if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'ALTER TABLE knowledge_bases ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP' ) ) else: # SQLite doesn't support DEFAULT CURRENT_TIMESTAMP in ALTER TABLE # Add column without default first await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE knowledge_bases ADD COLUMN updated_at DATETIME') ) # Set initial updated_at values to created_at for existing records await self.ap.persistence_mgr.execute_async( sqlalchemy.text('UPDATE knowledge_bases SET updated_at = created_at WHERE updated_at IS NULL') ) async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py ================================================ import sqlalchemy from .. import migration @migration.migration_class(14) class DBMigrateSpaceAccountSupport(migration.DBMigration): """Add Space account support fields to users table""" async def upgrade(self): """Upgrade""" # Get all column names from the users table columns = [] if self.ap.persistence_mgr.db.name == 'postgresql': result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text("SELECT column_name FROM information_schema.columns WHERE table_name = 'users';") ) all_result = result.fetchall() columns = [row[0] for row in all_result] else: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(users);')) all_result = result.fetchall() columns = [row[1] for row in all_result] # Add account_type column if 'account_type' not in columns: if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL") ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL") ) # Add space_account_uuid column if 'space_account_uuid' not in columns: if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)') ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)') ) # Add space_access_token column if 'space_access_token' not in columns: if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT') ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT') ) # Add space_refresh_token column if 'space_refresh_token' not in columns: if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT') ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT') ) # Add space_access_token_expires_at column if 'space_access_token_expires_at' not in columns: if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at TIMESTAMP') ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at DATETIME') ) # Add space_api_key column if 'space_api_key' not in columns: if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)') ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)') ) async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py ================================================ from .. import migration # this is a deprecated migration @migration.migration_class(15) class DBMigrateModelSourceTracking(migration.DBMigration): """Add source tracking fields to models tables for Space integration""" async def upgrade(self): """Upgrade""" pass async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py ================================================ import uuid as uuid_lib import sqlalchemy from .. import migration @migration.migration_class(16) class DBMigrateModelProviderRefactor(migration.DBMigration): """Refactor model structure: create providers from existing models and update references""" async def upgrade(self): """Upgrade""" # Step 1: Create model_providers table if not exists await self._create_providers_table() # Step 2: Migrate existing models to use providers await self._migrate_llm_models() await self._migrate_embedding_models() # Step 3: Remove deprecated columns await self._cleanup_columns() async def _create_providers_table(self): """Create model_providers table""" if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text(""" CREATE TABLE IF NOT EXISTS model_providers ( uuid VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, requester VARCHAR(255) NOT NULL, base_url VARCHAR(512) NOT NULL, api_keys JSONB NOT NULL DEFAULT '[]', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """) ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text(""" CREATE TABLE IF NOT EXISTS model_providers ( uuid VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, requester VARCHAR(255) NOT NULL, base_url VARCHAR(512) NOT NULL, api_keys JSON NOT NULL DEFAULT '[]', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) """) ) async def _migrate_llm_models(self): """Migrate LLM models to use providers""" llm_columns = await self._get_columns('llm_models') # Add provider_uuid column if not exists if 'provider_uuid' not in llm_columns: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN provider_uuid VARCHAR(255)') ) # Add prefered_ranking column if not exists if 'prefered_ranking' not in llm_columns: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0') ) # Only migrate if old columns exist if 'requester' not in llm_columns: return # Get all LLM models with old structure result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM llm_models') ) models = result.fetchall() # Create providers and update models provider_cache = {} # (requester, base_url, api_keys_str) -> provider_uuid for model in models: model_uuid, model_name, requester, requester_config, api_keys = model # Extract base_url from requester_config base_url = '' if requester_config: if isinstance(requester_config, str): import json requester_config = json.loads(requester_config) base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '') # Parse api_keys if it's a string if isinstance(api_keys, str): import json try: api_keys = json.loads(api_keys) except Exception: api_keys = [] if not api_keys: api_keys = [] # Create cache key api_keys_str = str(sorted(api_keys)) if api_keys else '[]' cache_key = (requester, base_url, api_keys_str) if cache_key in provider_cache: provider_uuid = provider_cache[cache_key] else: # Create new provider provider_uuid = str(uuid_lib.uuid4()) provider_name = f'{requester}' if base_url: # Extract domain for name try: from urllib.parse import urlparse parsed = urlparse(base_url) provider_name = parsed.netloc or requester except Exception: pass import json api_keys_json = json.dumps(api_keys) if api_keys else '[]' await self.ap.persistence_mgr.execute_async( sqlalchemy.text(""" INSERT INTO model_providers (uuid, name, requester, base_url, api_keys) VALUES (:uuid, :name, :requester, :base_url, :api_keys) """), { 'uuid': provider_uuid, 'name': provider_name, 'requester': requester, 'base_url': base_url, 'api_keys': api_keys_json, }, ) provider_cache[cache_key] = provider_uuid # Update model with provider_uuid await self.ap.persistence_mgr.execute_async( sqlalchemy.text('UPDATE llm_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'), {'provider_uuid': provider_uuid, 'uuid': model_uuid}, ) async def _migrate_embedding_models(self): """Migrate embedding models to use providers""" embedding_columns = await self._get_columns('embedding_models') # Add provider_uuid column if not exists if 'provider_uuid' not in embedding_columns: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN provider_uuid VARCHAR(255)') ) # Add prefered_ranking column if not exists if 'prefered_ranking' not in embedding_columns: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0') ) # Only migrate if old columns exist if 'requester' not in embedding_columns: return # Get all embedding models with old structure result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM embedding_models') ) models = result.fetchall() # Get existing providers provider_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, requester, base_url, api_keys FROM model_providers') ) existing_providers = provider_result.fetchall() provider_cache = {} for p in existing_providers: p_uuid, p_requester, p_base_url, p_api_keys = p api_keys_str = str(sorted(p_api_keys)) if p_api_keys else '[]' provider_cache[(p_requester, p_base_url, api_keys_str)] = p_uuid for model in models: model_uuid, model_name, requester, requester_config, api_keys = model base_url = '' if requester_config: if isinstance(requester_config, str): import json requester_config = json.loads(requester_config) base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '') # Parse api_keys if it's a string if isinstance(api_keys, str): import json try: api_keys = json.loads(api_keys) except Exception: api_keys = [] if not api_keys: api_keys = [] api_keys_str = str(sorted(api_keys)) if api_keys else '[]' cache_key = (requester, base_url, api_keys_str) if cache_key in provider_cache: provider_uuid = provider_cache[cache_key] else: provider_uuid = str(uuid_lib.uuid4()) provider_name = f'{requester}' if base_url: try: from urllib.parse import urlparse parsed = urlparse(base_url) provider_name = parsed.netloc or requester except Exception: pass import json api_keys_json = json.dumps(api_keys) if api_keys else '[]' await self.ap.persistence_mgr.execute_async( sqlalchemy.text(""" INSERT INTO model_providers (uuid, name, requester, base_url, api_keys) VALUES (:uuid, :name, :requester, :base_url, :api_keys) """), { 'uuid': provider_uuid, 'name': provider_name, 'requester': requester, 'base_url': base_url, 'api_keys': api_keys_json, }, ) provider_cache[cache_key] = provider_uuid await self.ap.persistence_mgr.execute_async( sqlalchemy.text('UPDATE embedding_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'), {'provider_uuid': provider_uuid, 'uuid': model_uuid}, ) async def _cleanup_columns(self): """Remove deprecated columns from model tables""" llm_columns = await self._get_columns('llm_models') deprecated_llm_cols = ['requester', 'requester_config', 'api_keys', 'description', 'source', 'space_model_id'] for col in deprecated_llm_cols: if col in llm_columns: if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN IF EXISTS {col}') ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN {col}') ) embedding_columns = await self._get_columns('embedding_models') deprecated_embedding_cols = [ 'requester', 'requester_config', 'api_keys', 'description', 'source', 'space_model_id', ] for col in deprecated_embedding_cols: if col in embedding_columns: if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN IF EXISTS {col}') ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN {col}') ) async def _get_columns(self, table_name: str) -> list: """Get column names for a table""" if self.ap.persistence_mgr.db.name == 'postgresql': result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text( f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';" ) ) all_result = result.fetchall() return [row[0] for row in all_result] else: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) all_result = result.fetchall() return [row[1] for row in all_result] async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py ================================================ from .. import migration @migration.migration_class(17) class MoveCloudServiceUrl(migration.DBMigration): """迁移云服务 URL 配置""" async def upgrade(self): """升级""" if 'space' not in self.ap.instance_config.data: self.ap.instance_config.data['space'] = { 'url': 'https://space.langbot.app', 'models_gateway_api_url': 'https://api.langbot.cloud/v1', 'oauth_authorize_url': 'https://space.langbot.app/auth/authorize', 'disable_models_service': False, } if 'plugin' in self.ap.instance_config.data: self.ap.instance_config.data['plugin'].pop('cloud_service_url', None) await self.ap.instance_config.dump_config() async def downgrade(self): """降级""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py ================================================ import sqlalchemy from .. import migration @migration.migration_class(18) class DBMigrateAddEmojiSupport(migration.DBMigration): """Add emoji field to knowledge_bases, external_knowledge_bases and legacy_pipelines tables""" async def upgrade(self): """Upgrade""" # Add emoji field to knowledge_bases await self._add_emoji_to_table('knowledge_bases', '📚') # Add emoji field to external_knowledge_bases await self._add_emoji_to_table('external_knowledge_bases', '🔗') # Add emoji field to legacy_pipelines await self._add_emoji_to_table('legacy_pipelines', '⚙️') async def _add_emoji_to_table(self, table_name: str, default_emoji: str): """Add emoji column to specified table if it doesn't exist""" # Get all column names from the table columns = [] if self.ap.persistence_mgr.db.name == 'postgresql': result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text( f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';" ) ) all_result = result.fetchall() columns = [row[0] for row in all_result] else: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) all_result = result.fetchall() columns = [row[1] for row in all_result] # Check and add emoji column if 'emoji' not in columns: if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text(f"ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10) DEFAULT '{default_emoji}'") ) else: # SQLite doesn't support DEFAULT with emoji directly in ALTER TABLE # Add column without default first await self.ap.persistence_mgr.execute_async( sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10)') ) # Set default emoji value for existing records await self.ap.persistence_mgr.execute_async( sqlalchemy.text(f"UPDATE {table_name} SET emoji = '{default_emoji}' WHERE emoji IS NULL") ) async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py ================================================ import sqlalchemy from .. import migration @migration.migration_class(19) class DBMigrateMonitoringMessageRole(migration.DBMigration): """Add role column to monitoring_messages table""" async def upgrade(self): """Upgrade""" try: sql_text = sqlalchemy.text("ALTER TABLE monitoring_messages ADD COLUMN role VARCHAR(50) DEFAULT 'user'") await self.ap.persistence_mgr.execute_async(sql_text) except Exception: # Column may already exist pass async def downgrade(self): """Downgrade""" try: sql_text = sqlalchemy.text('ALTER TABLE monitoring_messages DROP COLUMN role') await self.ap.persistence_mgr.execute_async(sql_text) except Exception: pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py ================================================ import sqlalchemy from .. import migration @migration.migration_class(20) class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration): """Migrate to unified Knowledge Engine plugin architecture. Changes: - Backup existing knowledge_bases data to knowledge_bases_backup - Clear knowledge_bases table and add new plugin architecture columns - Drop old columns (PostgreSQL only; SQLite leaves them unmapped) - Preserve external_knowledge_bases table as-is for future migration - Set rag_plugin_migration_needed flag in metadata if old data exists """ async def upgrade(self): """Upgrade""" has_internal_data = await self._backup_knowledge_bases() has_external_data = await self._check_external_knowledge_bases() await self._clear_knowledge_bases() await self._add_columns_to_knowledge_bases() await self._drop_old_columns() if has_internal_data or has_external_data: await self._set_migration_flag() async def _get_table_columns(self, table_name: str) -> list[str]: """Get column names from a table (works for both SQLite and PostgreSQL).""" if self.ap.persistence_mgr.db.name == 'postgresql': result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;' ).bindparams(table_name=table_name) ) return [row[0] for row in result.fetchall()] else: # SQLite PRAGMA does not support bind parameters; validate identifier. if not table_name.isidentifier(): raise ValueError(f'Invalid table name: {table_name}') result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) return [row[1] for row in result.fetchall()] async def _table_exists(self, table_name: str) -> bool: """Check if a table exists.""" if self.ap.persistence_mgr.db.name == 'postgresql': result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);' ).bindparams(table_name=table_name) ) return result.scalar() else: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams( table_name=table_name ) ) return result.first() is not None async def _backup_knowledge_bases(self) -> bool: """Backup knowledge_bases data. Returns True if data was backed up.""" result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases;')) count = result.scalar() if count == 0: return False # Drop backup table if it already exists (from a previous failed migration) if await self._table_exists('knowledge_bases_backup'): await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE knowledge_bases_backup;')) await self.ap.persistence_mgr.execute_async( sqlalchemy.text('CREATE TABLE knowledge_bases_backup AS SELECT * FROM knowledge_bases;') ) self.ap.logger.info( 'Backed up %d knowledge base(s) to knowledge_bases_backup table.', count, ) return True async def _check_external_knowledge_bases(self) -> bool: """Check if external_knowledge_bases table exists and has data. The table is preserved as-is (not dropped) for future migration. """ if not await self._table_exists('external_knowledge_bases'): return False result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;') ) count = result.scalar() if count > 0: self.ap.logger.info( 'Found %d external knowledge base(s) in external_knowledge_bases table. ' 'Table preserved for future migration.', count, ) return count > 0 async def _clear_knowledge_bases(self): """Clear all rows from knowledge_bases table (preserve table structure).""" await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DELETE FROM knowledge_bases;')) async def _add_columns_to_knowledge_bases(self): """Add new RAG plugin architecture columns to knowledge_bases table.""" columns = await self._get_table_columns('knowledge_bases') new_columns = { 'knowledge_engine_plugin_id': 'VARCHAR', 'collection_id': 'VARCHAR', 'creation_settings': 'TEXT', # JSON stored as TEXT for SQLite compatibility 'retrieval_settings': 'TEXT', } for col_name, col_type in new_columns.items(): if col_name not in columns: await self.ap.persistence_mgr.execute_async( sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};') ) async def _drop_old_columns(self): """Drop embedding_model_uuid and top_k columns (PostgreSQL only). SQLite does not support DROP COLUMN in older versions, so we leave the columns in place — the SQLAlchemy entity simply won't map them. """ if self.ap.persistence_mgr.db.name != 'postgresql': return columns = await self._get_table_columns('knowledge_bases') if 'embedding_model_uuid' in columns: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN embedding_model_uuid;') ) if 'top_k' in columns: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;') ) async def _set_migration_flag(self): """Set rag_plugin_migration_needed flag in metadata table.""" # Check if the key already exists result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text("SELECT value FROM metadata WHERE key = 'rag_plugin_migration_needed';") ) row = result.first() if row is not None: await self.ap.persistence_mgr.execute_async( sqlalchemy.text("UPDATE metadata SET value = 'true' WHERE key = 'rag_plugin_migration_needed';") ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text("INSERT INTO metadata (key, value) VALUES ('rag_plugin_migration_needed', 'true');") ) self.ap.logger.info('Set rag_plugin_migration_needed=true in metadata.') async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py ================================================ from .. import migration import sqlalchemy import json @migration.migration_class(21) class DBMigrateMergeExceptionHandling(migration.DBMigration): """Merge hide-exception and block-failed-request-output into a single exception-handling select option, and add failure-hint field. Conversion logic: - block-failed-request-output=true -> exception-handling: hide - hide-exception=true -> exception-handling: show-hint - hide-exception=false -> exception-handling: show-error """ async def upgrade(self): """Upgrade""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] if 'output' not in config: config['output'] = {} if 'misc' not in config['output']: config['output']['misc'] = {} misc = config['output']['misc'] # Determine new exception-handling value from legacy fields hide_exception = misc.get('hide-exception', True) block_failed = misc.get('block-failed-request-output', False) if block_failed: exception_handling = 'hide' elif hide_exception: exception_handling = 'show-hint' else: exception_handling = 'show-error' misc['exception-handling'] = exception_handling # Add failure-hint with default value misc['failure-hint'] = 'Request failed.' # Remove legacy fields misc.pop('hide-exception', None) if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py ================================================ import sqlalchemy from .. import migration @migration.migration_class(22) class DBMigrateMonitoringUserId(migration.DBMigration): """Add user_id and user_name columns to monitoring_sessions table This migration adds the missing user_id column and also ensures user_name column exists (in case migration 21 failed or was skipped). """ async def _table_exists(self, table_name: str) -> bool: """Check if a table exists (works for both SQLite and PostgreSQL).""" if self.ap.persistence_mgr.db.name == 'postgresql': result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);' ).bindparams(table_name=table_name) ) return bool(result.scalar()) else: result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams( table_name=table_name ) ) return result.first() is not None async def _get_table_columns(self, table_name: str) -> list[str]: """Get column names from a table (works for both SQLite and PostgreSQL).""" if self.ap.persistence_mgr.db.name == 'postgresql': result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;' ).bindparams(table_name=table_name) ) return [row[0] for row in result.fetchall()] else: if not table_name.isidentifier(): raise ValueError(f'Invalid table name: {table_name}') result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) return [row[1] for row in result.fetchall()] async def _add_column_if_not_exists(self, table_name: str, column_name: str, column_type: str): """Add a column to a table if it does not already exist.""" columns = await self._get_table_columns(table_name) if column_name in columns: self.ap.logger.debug('%s column already exists in %s.', column_name, table_name) return await self.ap.persistence_mgr.execute_async( sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type};') ) self.ap.logger.info('Added %s column to %s table.', column_name, table_name) async def upgrade(self): # Check if monitoring_sessions table exists if not await self._table_exists('monitoring_sessions'): self.ap.logger.warning('monitoring_sessions table does not exist, skipping migration.') return # Add user_id column to monitoring_sessions table await self._add_column_if_not_exists('monitoring_sessions', 'user_id', 'VARCHAR(255)') # Add user_name column to monitoring_sessions table (in case migration 21 failed) await self._add_column_if_not_exists('monitoring_sessions', 'user_name', 'VARCHAR(255)') # Add user_name column to monitoring_messages table (in case migration 21 failed) if await self._table_exists('monitoring_messages'): await self._add_column_if_not_exists('monitoring_messages', 'user_name', 'VARCHAR(255)') async def downgrade(self): pass ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py ================================================ from .. import migration import sqlalchemy import json @migration.migration_class(23) class DBMigrateModelFallbackConfig(migration.DBMigration): """Convert model field from plain UUID string to object with primary/fallbacks""" async def upgrade(self): """Upgrade""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] if 'ai' not in config or 'local-agent' not in config['ai']: continue local_agent = config['ai']['local-agent'] changed = False # Convert model from string to object model_value = local_agent.get('model', '') if isinstance(model_value, str): local_agent['model'] = { 'primary': model_value, 'fallbacks': [], } changed = True # Remove leftover fallback-models field if present if 'fallback-models' in local_agent: del local_agent['fallback-models'] changed = True if not changed: continue # Update using raw SQL with compatibility for both SQLite and PostgreSQL if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) async def downgrade(self): """Downgrade""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') ) pipelines = result.fetchall() current_version = self.ap.ver_mgr.get_current_version() for pipeline_row in pipelines: uuid = pipeline_row[0] config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] if 'ai' not in config or 'local-agent' not in config['ai']: continue local_agent = config['ai']['local-agent'] # Convert model from object back to string model_value = local_agent.get('model', '') if isinstance(model_value, dict): local_agent['model'] = model_value.get('primary', '') else: continue # Update using raw SQL with compatibility for both SQLite and PostgreSQL if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text( 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' ), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, ) ================================================ FILE: src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py ================================================ from .. import migration import sqlalchemy import json @migration.migration_class(24) class DBMigrateWecomBotWebSocketMode(migration.DBMigration): """Add enable-webhook field to existing wecombot adapter configs. Existing wecombot bots were all using webhook mode, so we set enable-webhook=true to preserve their behavior after the new WebSocket long connection mode is introduced as default. """ async def upgrade(self): """Upgrade""" result = await self.ap.persistence_mgr.execute_async( sqlalchemy.text("SELECT uuid, adapter_config FROM bots WHERE adapter = 'wecombot'") ) bots = result.fetchall() for bot_row in bots: bot_uuid = bot_row[0] adapter_config = json.loads(bot_row[1]) if isinstance(bot_row[1], str) else bot_row[1] if 'enable-webhook' in adapter_config: continue # Determine mode based on existing config: if webhook fields are present, keep webhook mode has_webhook_config = bool( adapter_config.get('Token') and adapter_config.get('EncodingAESKey') and adapter_config.get('Corpid') ) adapter_config['enable-webhook'] = has_webhook_config if self.ap.persistence_mgr.db.name == 'postgresql': await self.ap.persistence_mgr.execute_async( sqlalchemy.text('UPDATE bots SET adapter_config = :config::jsonb WHERE uuid = :uuid'), {'config': json.dumps(adapter_config), 'uuid': bot_uuid}, ) else: await self.ap.persistence_mgr.execute_async( sqlalchemy.text('UPDATE bots SET adapter_config = :config WHERE uuid = :uuid'), {'config': json.dumps(adapter_config), 'uuid': bot_uuid}, ) async def downgrade(self): """Downgrade""" pass ================================================ FILE: src/langbot/pkg/pipeline/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/aggregator.py ================================================ """Message Aggregator Module This module provides message aggregation/debounce functionality. When users send multiple messages consecutively, the aggregator will wait for a configurable delay period and merge them into a single message before processing. """ from __future__ import annotations import asyncio import time import typing from dataclasses import dataclass, field import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter if typing.TYPE_CHECKING: from ..core import app # Maximum number of messages to buffer before forcing a flush MAX_BUFFER_MESSAGES = 10 @dataclass class PendingMessage: """A pending message waiting to be aggregated""" bot_uuid: str launcher_type: provider_session.LauncherTypes launcher_id: typing.Union[int, str] sender_id: typing.Union[int, str] message_event: platform_events.MessageEvent message_chain: platform_message.MessageChain adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter pipeline_uuid: typing.Optional[str] timestamp: float = field(default_factory=time.time) @dataclass class SessionBuffer: """Buffer for a single session's pending messages""" session_id: str messages: list[PendingMessage] = field(default_factory=list) timer_task: typing.Optional[asyncio.Task] = None last_message_time: float = field(default_factory=time.time) class MessageAggregator: """Message aggregator that buffers and merges consecutive messages This class implements a debounce mechanism for incoming messages. When a message arrives, it starts a timer. If more messages arrive before the timer expires, they are buffered. When the timer expires, all buffered messages are merged and sent to the query pool. """ ap: app.Application buffers: dict[str, SessionBuffer] """Session ID -> SessionBuffer mapping""" lock: asyncio.Lock """Lock for thread-safe buffer operations""" def __init__(self, ap: app.Application): self.ap = ap self.buffers = {} self.lock = asyncio.Lock() def _get_session_id( self, bot_uuid: str, launcher_type: provider_session.LauncherTypes, launcher_id: typing.Union[int, str], ) -> str: """Generate a unique session ID""" return f'{bot_uuid}:{launcher_type.value}:{launcher_id}' async def _get_aggregation_config(self, pipeline_uuid: typing.Optional[str]) -> tuple[bool, float]: """Get aggregation configuration for a pipeline Returns: tuple: (enabled, delay_seconds) """ default_enabled = False default_delay = 1.5 if pipeline_uuid is None: return default_enabled, default_delay # Get pipeline from pipeline manager pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid) if pipeline is None: return default_enabled, default_delay config = pipeline.pipeline_entity.config or {} trigger_config = config.get('trigger', {}) aggregation_config = trigger_config.get('message-aggregation', {}) enabled = aggregation_config.get('enabled', default_enabled) delay_raw = aggregation_config.get('delay', default_delay) try: delay = float(delay_raw) except (TypeError, ValueError): delay = default_delay # Clamp delay to valid range delay = max(1.0, min(10.0, delay)) return enabled, delay async def add_message( self, bot_uuid: str, launcher_type: provider_session.LauncherTypes, launcher_id: typing.Union[int, str], sender_id: typing.Union[int, str], message_event: platform_events.MessageEvent, message_chain: platform_message.MessageChain, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, pipeline_uuid: typing.Optional[str] = None, ) -> None: """Add a message to the aggregation buffer If aggregation is disabled for the pipeline, the message is sent directly to the query pool. Otherwise, it's buffered and will be merged with other messages from the same session. """ enabled, delay = await self._get_aggregation_config(pipeline_uuid) if not enabled: # Aggregation disabled, send directly to query pool await self.ap.query_pool.add_query( bot_uuid=bot_uuid, launcher_type=launcher_type, launcher_id=launcher_id, sender_id=sender_id, message_event=message_event, message_chain=message_chain, adapter=adapter, pipeline_uuid=pipeline_uuid, ) return session_id = self._get_session_id(bot_uuid, launcher_type, launcher_id) pending_msg = PendingMessage( bot_uuid=bot_uuid, launcher_type=launcher_type, launcher_id=launcher_id, sender_id=sender_id, message_event=message_event, message_chain=message_chain, adapter=adapter, pipeline_uuid=pipeline_uuid, ) force_flush = False async with self.lock: if session_id in self.buffers: buffer = self.buffers[session_id] # Cancel existing timer (just cancel, don't await inside lock) if buffer.timer_task and not buffer.timer_task.done(): buffer.timer_task.cancel() buffer.messages.append(pending_msg) else: buffer = SessionBuffer( session_id=session_id, messages=[pending_msg], ) self.buffers[session_id] = buffer buffer.last_message_time = time.time() # Check if buffer reached max capacity if len(buffer.messages) >= MAX_BUFFER_MESSAGES: force_flush = True else: # Start new timer buffer.timer_task = asyncio.create_task(self._delayed_flush(session_id, delay)) if force_flush: await self._flush_buffer(session_id) async def _delayed_flush(self, session_id: str, delay: float) -> None: """Wait for delay then flush the buffer""" try: await asyncio.sleep(delay) await self._flush_buffer(session_id) except asyncio.CancelledError: # Timer was cancelled, new message arrived pass async def _flush_buffer(self, session_id: str) -> None: """Flush the buffer for a session, merging all messages""" async with self.lock: buffer = self.buffers.pop(session_id, None) if buffer is None or not buffer.messages: return if len(buffer.messages) == 1: # Only one message, no need to merge msg = buffer.messages[0] await self.ap.query_pool.add_query( bot_uuid=msg.bot_uuid, launcher_type=msg.launcher_type, launcher_id=msg.launcher_id, sender_id=msg.sender_id, message_event=msg.message_event, message_chain=msg.message_chain, adapter=msg.adapter, pipeline_uuid=msg.pipeline_uuid, ) return # Merge multiple messages merged_msg = self._merge_messages(buffer.messages) await self.ap.query_pool.add_query( bot_uuid=merged_msg.bot_uuid, launcher_type=merged_msg.launcher_type, launcher_id=merged_msg.launcher_id, sender_id=merged_msg.sender_id, message_event=merged_msg.message_event, message_chain=merged_msg.message_chain, adapter=merged_msg.adapter, pipeline_uuid=merged_msg.pipeline_uuid, ) def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage: """Merge multiple messages into one The merged message uses the first message as base and combines all message chains with newline separators. The original message_event is kept unmodified to preserve message metadata (message_id, etc.) for reply/quote. """ if len(messages) == 1: return messages[0] base_msg = messages[0] # Build merged message chain merged_chain = platform_message.MessageChain([]) for i, msg in enumerate(messages): if i > 0: # Add newline separator between messages merged_chain.append(platform_message.Plain(text='\n')) # Copy all components from this message for component in msg.message_chain: merged_chain.append(component) # Keep message_event unmodified (preserves original message_id and # metadata for reply/quote), only pass merged chain separately return PendingMessage( bot_uuid=base_msg.bot_uuid, launcher_type=base_msg.launcher_type, launcher_id=base_msg.launcher_id, sender_id=base_msg.sender_id, message_event=base_msg.message_event, message_chain=merged_chain, adapter=base_msg.adapter, pipeline_uuid=base_msg.pipeline_uuid, ) async def flush_all(self) -> None: """Flush all pending buffers immediately This is useful during shutdown to ensure no messages are lost. """ # Snapshot session IDs and cancel all timers under lock async with self.lock: session_ids = list(self.buffers.keys()) for sid in session_ids: buffer = self.buffers.get(sid) if buffer and buffer.timer_task and not buffer.timer_task.done(): buffer.timer_task.cancel() # Flush each buffer outside the lock for session_id in session_ids: await self._flush_buffer(session_id) ================================================ FILE: src/langbot/pkg/pipeline/bansess/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/bansess/bansess.py ================================================ from __future__ import annotations from .. import stage, entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @stage.stage_class('BanSessionCheckStage') class BanSessionCheckStage(stage.PipelineStage): """Access control processing stage Only check if the group or personal number in the query is in the access control list. """ async def initialize(self, pipeline_config: dict): pass async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: found = False mode = query.pipeline_config['trigger']['access-control']['mode'] sess_list = query.pipeline_config['trigger']['access-control'][mode] if (query.launcher_type.value == 'group' and 'group_*' in sess_list) or ( query.launcher_type.value == 'person' and 'person_*' in sess_list ): found = True else: for sess in sess_list: if sess == f'{query.launcher_type.value}_{query.launcher_id}': found = True break # 使用 *_id 来表示加白/拉黑某用户的私聊和群聊场景 if sess.startswith('*_') and (sess[2:] == query.launcher_id or sess[2:] == query.sender_id): found = True break ctn = False if mode == 'whitelist': ctn = found else: ctn = not found return entities.StageProcessResult( result_type=entities.ResultType.CONTINUE if ctn else entities.ResultType.INTERRUPT, new_query=query, console_notice=f'Ignore message according to access control: {query.launcher_type.value}_{query.launcher_id}' if not ctn else '', ) ================================================ FILE: src/langbot/pkg/pipeline/cntfilter/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/cntfilter/cntfilter.py ================================================ from __future__ import annotations from ...core import app from .. import stage, entities from . import filter as filter_model, entities as filter_entities from langbot_plugin.api.entities.builtin.provider import message as provider_message import langbot_plugin.api.entities.builtin.platform.message as platform_message from ...utils import importutil import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query from . import filters importutil.import_modules_in_pkg(filters) @stage.stage_class('PostContentFilterStage') @stage.stage_class('PreContentFilterStage') class ContentFilterStage(stage.PipelineStage): """内容过滤阶段 前置: 检查消息是否符合规则,不符合则拦截。 改写: message_chain 后置: 检查AI回复消息是否符合规则,可能进行改写,不符合则拦截。 改写: query.resp_messages """ filter_chain: list[filter_model.ContentFilter] def __init__(self, ap: app.Application): self.filter_chain = [] super().__init__(ap) async def initialize(self, pipeline_config: dict): filters_required = [ 'content-ignore', ] if pipeline_config['safety']['content-filter']['check-sensitive-words']: filters_required.append('ban-word-filter') # TODO revert it # if self.ap.pipeline_cfg.data['baidu-cloud-examine']['enable']: # filters_required.append("baidu-cloud-examine") for filter in filter_model.preregistered_filters: if filter.name in filters_required: self.filter_chain.append(filter(self.ap)) for filter in self.filter_chain: await filter.initialize() async def _pre_process( self, message: str, query: pipeline_query.Query, ) -> entities.StageProcessResult: """请求llm前处理消息 只要有一个不通过就不放行,只放行 PASS 的消息 """ if query.pipeline_config['safety']['content-filter']['scope'] == 'output-msg': return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) if not message.strip(): return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) else: for filter in self.filter_chain: if filter_entities.EnableStage.PRE in filter.enable_stages: result = await filter.process(query, message) if result.level in [ filter_entities.ResultLevel.BLOCK, filter_entities.ResultLevel.MASKED, ]: return entities.StageProcessResult( result_type=entities.ResultType.INTERRUPT, new_query=query, user_notice=result.user_notice, console_notice=result.console_notice, ) elif result.level == filter_entities.ResultLevel.PASS: # 传到下一个 message = result.replacement query.message_chain = platform_message.MessageChain([platform_message.Plain(text=message)]) return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) async def _post_process( self, message: str, query: pipeline_query.Query, ) -> entities.StageProcessResult: """请求llm后处理响应 只要是 PASS 或者 MASKED 的就通过此 filter,将其 replacement 设置为message,进入下一个 filter """ if query.pipeline_config['safety']['content-filter']['scope'] == 'income-msg': return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) else: message = message.strip() for filter in self.filter_chain: if filter_entities.EnableStage.POST in filter.enable_stages: result = await filter.process(query, message) if result.level == filter_entities.ResultLevel.BLOCK: return entities.StageProcessResult( result_type=entities.ResultType.INTERRUPT, new_query=query, user_notice=result.user_notice, console_notice=result.console_notice, ) elif result.level in [ filter_entities.ResultLevel.PASS, filter_entities.ResultLevel.MASKED, ]: message = result.replacement query.resp_messages[-1].content = message return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: """处理""" if stage_inst_name == 'PreContentFilterStage': contain_non_text = False text_components = [platform_message.Plain, platform_message.Source] for me in query.message_chain: if type(me) not in text_components: contain_non_text = True break if contain_non_text: self.ap.logger.debug('消息中包含非文本消息,跳过内容过滤器检查。') return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) return await self._pre_process(str(query.message_chain).strip(), query) elif stage_inst_name == 'PostContentFilterStage': # 仅处理 query.resp_messages[-1].content 是 str 的情况 if isinstance(query.resp_messages[-1], provider_message.Message) and isinstance( query.resp_messages[-1].content, str ): return await self._post_process(query.resp_messages[-1].content, query) else: self.ap.logger.debug( 'resp_messages[-1] 不是 Message 类型或 query.resp_messages[-1].content 不是 str 类型,跳过内容过滤器检查。' ) return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) else: raise ValueError(f'未知的 stage_inst_name: {stage_inst_name}') ================================================ FILE: src/langbot/pkg/pipeline/cntfilter/entities.py ================================================ import enum import pydantic class ResultLevel(enum.Enum): """结果等级""" PASS = enum.auto() """通过""" WARN = enum.auto() """警告""" MASKED = enum.auto() """已掩去""" BLOCK = enum.auto() """阻止""" class EnableStage(enum.Enum): """启用阶段""" PRE = enum.auto() """预处理""" POST = enum.auto() """后处理""" class FilterResult(pydantic.BaseModel): level: ResultLevel """结果等级 对于前置处理阶段,只要有任意一个返回 非PASS 的内容过滤器结果,就会中断处理。 对于后置处理阶段,当且内容过滤器返回 BLOCK 时,会中断处理。 """ replacement: str """替换后的文本消息 内容过滤器可以进行一些遮掩处理,然后把遮掩后的消息返回。 若没有修改内容,也需要返回原消息。 """ user_notice: str """不通过时,若此值不为空,将对用户提示消息""" console_notice: str """不通过时,若此值不为空,将在控制台提示消息""" class ManagerResultLevel(enum.Enum): """处理器结果等级""" CONTINUE = enum.auto() """继续""" INTERRUPT = enum.auto() """中断""" class FilterManagerResult(pydantic.BaseModel): level: ManagerResultLevel replacement: str """替换后的消息""" user_notice: str """用户提示消息""" console_notice: str """控制台提示消息""" ================================================ FILE: src/langbot/pkg/pipeline/cntfilter/filter.py ================================================ # 内容过滤器的抽象类 from __future__ import annotations import abc import typing from ...core import app from . import entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query preregistered_filters: list[typing.Type[ContentFilter]] = [] def filter_class( name: str, ) -> typing.Callable[[typing.Type[ContentFilter]], typing.Type[ContentFilter]]: """Content filter class decorator Args: name (str): Filter name Returns: typing.Callable[[typing.Type[ContentFilter]], typing.Type[ContentFilter]]: Decorator """ def decorator(cls: typing.Type[ContentFilter]) -> typing.Type[ContentFilter]: assert issubclass(cls, ContentFilter) cls.name = name preregistered_filters.append(cls) return cls return decorator class ContentFilter(metaclass=abc.ABCMeta): """Content filter abstract class""" name: str ap: app.Application def __init__(self, ap: app.Application): self.ap = ap @property def enable_stages(self): """Enabled stages Default is the two stages before and after the message request to AI. entity.EnableStage.PRE: Before message request to AI, the content to check is the user's input message. entity.EnableStage.POST: After message request to AI, the content to check is the AI's reply message. """ return [entities.EnableStage.PRE, entities.EnableStage.POST] async def initialize(self): """Initialize filter""" pass @abc.abstractmethod async def process(self, query: pipeline_query.Query, message: str = None, image_url=None) -> entities.FilterResult: """处理消息 It is divided into two stages, depending on the value of enable_stages. For content filters, you do not need to consider the stage of the message, you only need to check the message content. Args: message (str): Content to check image_url (str): URL of the image to check Returns: entities.FilterResult: Filter result, please refer to the documentation of entities.FilterResult class """ raise NotImplementedError ================================================ FILE: src/langbot/pkg/pipeline/cntfilter/filters/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py ================================================ from __future__ import annotations from .. import entities from .. import filter as filter_model import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query from langbot.pkg.utils import httpclient BAIDU_EXAMINE_URL = 'https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined?access_token={}' BAIDU_EXAMINE_TOKEN_URL = 'https://aip.baidubce.com/oauth/2.0/token' @filter_model.filter_class('baidu-cloud-examine') class BaiduCloudExamine(filter_model.ContentFilter): """百度云内容审核""" async def _get_token(self) -> str: session = httpclient.get_session() async with session.post( BAIDU_EXAMINE_TOKEN_URL, params={ 'grant_type': 'client_credentials', 'client_id': self.ap.pipeline_cfg.data['baidu-cloud-examine']['api-key'], 'client_secret': self.ap.pipeline_cfg.data['baidu-cloud-examine']['api-secret'], }, ) as resp: return (await resp.json())['access_token'] async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult: session = httpclient.get_session() async with session.post( BAIDU_EXAMINE_URL.format(await self._get_token()), headers={ 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, data=f'text={message}'.encode('utf-8'), ) as resp: result = await resp.json() if 'error_code' in result: return entities.FilterResult( level=entities.ResultLevel.BLOCK, replacement=message, user_notice='', console_notice=f'百度云判定出错,错误信息:{result["error_msg"]}', ) else: conclusion = result['conclusion'] if conclusion in ('合规'): return entities.FilterResult( level=entities.ResultLevel.PASS, replacement=message, user_notice='', console_notice=f'百度云判定结果:{conclusion}', ) else: return entities.FilterResult( level=entities.ResultLevel.BLOCK, replacement=message, user_notice='消息中存在不合适的内容, 请修改', console_notice=f'百度云判定结果:{conclusion}', ) ================================================ FILE: src/langbot/pkg/pipeline/cntfilter/filters/banwords.py ================================================ from __future__ import annotations import re from .. import filter as filter_model from .. import entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @filter_model.filter_class('ban-word-filter') class BanWordFilter(filter_model.ContentFilter): """Filter content""" async def initialize(self): pass async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult: found = False for word in self.ap.sensitive_meta.data['words']: match = re.findall(word, message) if len(match) > 0: found = True for i in range(len(match)): if self.ap.sensitive_meta.data['mask_word'] == '': message = message.replace( match[i], self.ap.sensitive_meta.data['mask'] * len(match[i]), ) else: message = message.replace(match[i], self.ap.sensitive_meta.data['mask_word']) return entities.FilterResult( level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS, replacement=message, user_notice='消息中存在不合适的内容, 请修改' if found else '', console_notice='', ) ================================================ FILE: src/langbot/pkg/pipeline/cntfilter/filters/cntignore.py ================================================ from __future__ import annotations import re from .. import entities from .. import filter as filter_model import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @filter_model.filter_class('content-ignore') class ContentIgnore(filter_model.ContentFilter): """Ignore message according to content""" @property def enable_stages(self): return [ entities.EnableStage.PRE, ] async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult: if 'prefix' in query.pipeline_config['trigger']['ignore-rules']: for rule in query.pipeline_config['trigger']['ignore-rules']['prefix']: if message.startswith(rule): return entities.FilterResult( level=entities.ResultLevel.BLOCK, replacement='', user_notice='', console_notice='Ignore message according to prefix rule in ignore_rules', ) if 'regexp' in query.pipeline_config['trigger']['ignore-rules']: for rule in query.pipeline_config['trigger']['ignore-rules']['regexp']: if re.search(rule, message): return entities.FilterResult( level=entities.ResultLevel.BLOCK, replacement='', user_notice='', console_notice='Ignore message according to regexp rule in ignore_rules', ) return entities.FilterResult( level=entities.ResultLevel.PASS, replacement=message, user_notice='', console_notice='', ) ================================================ FILE: src/langbot/pkg/pipeline/config_coercion.py ================================================ from __future__ import annotations import logging logger = logging.getLogger(__name__) # metadata type -> coercion function _COERCE_MAP = { 'integer': lambda v: int(v), 'number': lambda v: float(v), 'float': lambda v: float(v), } def _coerce_bool(v): if isinstance(v, bool): return v if isinstance(v, str): if v.lower() == 'true': return True if v.lower() == 'false': return False raise ValueError(f'Cannot convert string {v!r} to bool') return bool(v) def _coerce_value(value, expected_type: str): """Convert a single value to the expected type. Returns the converted value, or the original value if no conversion needed. """ if value is None: return value if expected_type == 'boolean': if isinstance(value, bool): return value return _coerce_bool(value) coerce_fn = _COERCE_MAP.get(expected_type) if coerce_fn is None: return value # Already the correct type if expected_type == 'integer' and isinstance(value, int) and not isinstance(value, bool): return value if expected_type in ('number', 'float') and isinstance(value, (int, float)) and not isinstance(value, bool): return float(value) return coerce_fn(value) def coerce_pipeline_config( config: dict, *metadata_list: dict, ) -> None: """Coerce pipeline config values according to metadata type definitions. Walks each metadata dict (trigger, safety, ai, output) and converts config values in-place so that strings coming from the JSON column are cast to their declared types (integer, number/float, boolean). Args: config: The pipeline config dict to modify in-place. *metadata_list: Metadata dicts loaded from the YAML templates. """ for meta in metadata_list: section_name = meta.get('name') if not section_name or section_name not in config: continue section = config[section_name] if not isinstance(section, dict): continue for stage_def in meta.get('stages', []): stage_name = stage_def.get('name') if not stage_name or stage_name not in section: continue stage_config = section[stage_name] if not isinstance(stage_config, dict): continue for field_def in stage_def.get('config', []): field_name = field_def.get('name') field_type = field_def.get('type') if not field_name or not field_type or field_name not in stage_config: continue old_value = stage_config[field_name] try: new_value = _coerce_value(old_value, field_type) if new_value is not old_value: stage_config[field_name] = new_value except (ValueError, TypeError) as e: logger.warning( 'Failed to coerce config %s.%s.%s (%r) to %s: %s', section_name, stage_name, field_name, old_value, field_type, e, ) ================================================ FILE: src/langbot/pkg/pipeline/controller.py ================================================ from __future__ import annotations import asyncio import traceback from ..core import app from ..core import entities as core_entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query class Controller: """总控制器""" ap: app.Application semaphore: asyncio.Semaphore = None """请求并发控制信号量""" def __init__(self, ap: app.Application): self.ap = ap self.semaphore = asyncio.Semaphore(self.ap.instance_config.data['concurrency']['pipeline']) async def consumer(self): """事件处理循环""" try: while True: selected_query: pipeline_query.Query = None # 取请求 async with self.ap.query_pool: queries: list[pipeline_query.Query] = self.ap.query_pool.queries for query in queries: session = await self.ap.sess_mgr.get_session(query) # Debug logging removed from tight loop to prevent excessive log generation # that can cause memory overflow in high-traffic scenarios if not session._semaphore.locked(): selected_query = query await session._semaphore.acquire() # Only log when actually selecting a query self.ap.logger.debug(f'Selected query {query.query_id} for processing') break if selected_query: # 找到了 queries.remove(selected_query) else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限 await self.ap.query_pool.condition.wait() continue if selected_query: async def _process_query(selected_query: pipeline_query.Query): async with self.semaphore: # 总并发上限 # find pipeline # Here firstly find the bot, then find the pipeline, in case the bot adapter's config is not the latest one. # Like aiocqhttp, once a client is connected, even the adapter was updated and restarted, the existing client connection will not be affected. pipeline_uuid = selected_query.pipeline_uuid if pipeline_uuid: pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid) if pipeline: await pipeline.run(selected_query) async with self.ap.query_pool: (await self.ap.sess_mgr.get_session(selected_query))._semaphore.release() # 通知其他协程,有新的请求可以处理了 self.ap.query_pool.condition.notify_all() self.ap.task_mgr.create_task( _process_query(selected_query), kind='query', name=f'query-{selected_query.query_id}', scopes=[ core_entities.LifecycleControlScope.APPLICATION, core_entities.LifecycleControlScope.PLATFORM, ], ) except Exception as e: # traceback.print_exc() self.ap.logger.error(f'控制器循环出错: {e}') self.ap.logger.error(f'Traceback: {traceback.format_exc()}') async def run(self): """运行控制器""" await self.consumer() ================================================ FILE: src/langbot/pkg/pipeline/entities.py ================================================ from __future__ import annotations import enum import typing import pydantic import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.platform.message as platform_message class ResultType(enum.Enum): CONTINUE = enum.auto() """继续流水线""" INTERRUPT = enum.auto() """中断流水线""" class StageProcessResult(pydantic.BaseModel): result_type: ResultType new_query: pipeline_query.Query user_notice: typing.Optional[ typing.Union[ str, list[platform_message.MessageComponent], platform_message.MessageChain, None, ] ] = [] """只要设置了就会发送给用户""" console_notice: typing.Optional[str] = '' """只要设置了就会输出到控制台""" debug_notice: typing.Optional[str] = '' error_notice: typing.Optional[str] = '' ================================================ FILE: src/langbot/pkg/pipeline/longtext/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/longtext/longtext.py ================================================ from __future__ import annotations import os import traceback from . import strategy from .. import stage, entities import langbot_plugin.api.entities.builtin.platform.message as platform_message from ...utils import importutil import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query from . import strategies importutil.import_modules_in_pkg(strategies) @stage.stage_class('LongTextProcessStage') class LongTextProcessStage(stage.PipelineStage): """Long message processing stage Rewrite: - resp_message_chain """ strategy_impl: strategy.LongTextStrategy | None async def initialize(self, pipeline_config: dict): config = pipeline_config['output']['long-text-processing'] if config['strategy'] == 'none': self.strategy_impl = None return if config['strategy'] == 'image': use_font = config['font-path'] try: # 检查是否存在 if not os.path.exists(use_font): # 若是windows系统,使用微软雅黑 if os.name == 'nt': use_font = 'C:/Windows/Fonts/msyh.ttc' if not os.path.exists(use_font): self.ap.logger.warn( 'Font file not found, and Windows system font cannot be used, switch to forward message component to send long messages, you can adjust the related settings in the configuration file.' ) config['blob_message_strategy'] = 'forward' else: self.ap.logger.info('Using Windows system font: ' + use_font) config['font-path'] = use_font else: self.ap.logger.warn( 'Font file not found, and system font cannot be used, switch to forward message component to send long messages, you can adjust the related settings in the configuration file.' ) pipeline_config['output']['long-text-processing']['strategy'] = 'forward' except Exception: traceback.print_exc() self.ap.logger.error( 'Failed to load font file ({}), switch to forward message component to send long messages, you can adjust the related settings in the configuration file.'.format( use_font ) ) pipeline_config['output']['long-text-processing']['strategy'] = 'forward' for strategy_cls in strategy.preregistered_strategies: if strategy_cls.name == config['strategy']: self.strategy_impl = strategy_cls(self.ap) break else: raise ValueError(f'Long message processing strategy not found: {config["strategy"]}') await self.strategy_impl.initialize() async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: if self.strategy_impl is None: self.ap.logger.debug('Long message processing strategy is not set, skip long message processing.') return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) # 检查是否包含非 Plain 组件 contains_non_plain = False for msg in query.resp_message_chain[-1]: if not isinstance(msg, platform_message.Plain): contains_non_plain = True break if contains_non_plain: self.ap.logger.debug('Message contains non-Plain components, skip long message processing.') elif ( len(str(query.resp_message_chain[-1])) > query.pipeline_config['output']['long-text-processing']['threshold'] ): query.resp_message_chain[-1] = platform_message.MessageChain( await self.strategy_impl.process(str(query.resp_message_chain[-1]), query) ) return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) ================================================ FILE: src/langbot/pkg/pipeline/longtext/strategies/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/longtext/strategies/forward.py ================================================ # 转发消息组件 from __future__ import annotations from .. import strategy as strategy_model import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.platform.message as platform_message ForwardMessageDiaplay = platform_message.ForwardMessageDiaplay Forward = platform_message.Forward @strategy_model.strategy_class('forward') class ForwardComponentStrategy(strategy_model.LongTextStrategy): async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]: display = ForwardMessageDiaplay( title='Group chat history', brief='[Chat history]', source='Chat history', preview=['User: ' + message], summary='View 1 forwarded message', ) node_list = [ platform_message.ForwardMessageNode( sender_id=query.adapter.bot_account_id, sender_name='User', message_chain=platform_message.MessageChain([platform_message.Plain(text=message)]), ) ] forward = Forward(display=display, node_list=node_list) return [forward] ================================================ FILE: src/langbot/pkg/pipeline/longtext/strategies/image.py ================================================ from __future__ import annotations import os import base64 import time import re from PIL import Image, ImageDraw, ImageFont import functools from .. import strategy as strategy_model import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.platform.message as platform_message @strategy_model.strategy_class('image') class Text2ImageStrategy(strategy_model.LongTextStrategy): async def initialize(self): pass @functools.lru_cache(maxsize=16) def get_font(self, font_path: str): return ImageFont.truetype( font_path, 32, encoding='utf-8', ) async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]: img_path = self.text_to_image( text_str=message, save_as='temp/{}.png'.format(int(time.time())), query=query, ) compressed_path, size = self.compress_image(img_path, outfile='temp/{}_compressed.png'.format(int(time.time()))) with open(compressed_path, 'rb') as f: img = f.read() b64 = base64.b64encode(img) # 删除图片 os.remove(img_path) if os.path.exists(compressed_path): os.remove(compressed_path) return [ platform_message.Image( base64=b64.decode('utf-8'), ) ] def indexNumber(self, path=''): """ 查找字符串中数字所在串中的位置 :param path:目标字符串 :return:: : [['1', 16], ['2', 35], ['1', 51]] """ kv = [] nums = [] beforeDatas = re.findall('[\\d]+', path) for num in beforeDatas: indexV = [] times = path.count(num) if times > 1: if num not in nums: indexs = re.finditer(num, path) for index in indexs: iV = [] i = index.span()[0] iV.append(num) iV.append(i) kv.append(iV) nums.append(num) else: index = path.find(num) indexV.append(num) indexV.append(index) kv.append(indexV) # 根据数字位置排序 indexSort = [] resultIndex = [] for vi in kv: indexSort.append(vi[1]) indexSort.sort() for i in indexSort: for v in kv: if i == v[1]: resultIndex.append(v) return resultIndex def get_size(self, file): # 获取文件大小:KB size = os.path.getsize(file) return size / 1024 def get_outfile(self, infile, outfile): if outfile: return outfile dir, suffix = os.path.splitext(infile) outfile = '{}-out{}'.format(dir, suffix) return outfile def compress_image(self, infile, outfile='', kb=100, step=20, quality=90): """不改变图片尺寸压缩到指定大小 :param infile: 压缩源文件 :param outfile: 压缩文件保存地址 :param mb: 压缩目标,KB :param step: 每次调整的压缩比率 :param quality: 初始压缩比率 :return: 压缩文件地址,压缩文件大小 """ o_size = self.get_size(infile) if o_size <= kb: return infile, o_size outfile = self.get_outfile(infile, outfile) while o_size > kb: im = Image.open(infile) im.save(outfile, quality=quality) if quality - step < 0: break quality -= step o_size = self.get_size(outfile) return outfile, self.get_size(outfile) def text_to_image( self, text_str: str, save_as='temp.png', width=800, query: pipeline_query.Query = None, ): text_str = text_str.replace('\t', ' ') # 分行 lines = text_str.split('\n') # 计算并分割 final_lines = [] text_width = width - 80 self.ap.logger.debug('lines: {}, text_width: {}'.format(lines, text_width)) for line in lines: # 如果长了就分割 line_width = self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']).getlength( line ) self.ap.logger.debug('line_width: {}'.format(line_width)) if line_width < text_width: final_lines.append(line) continue else: rest_text = line while True: # 分割最前面的一行 point = int(len(rest_text) * (text_width / line_width)) # 检查断点是否在数字中间 numbers = self.indexNumber(rest_text) for number in numbers: if number[1] < point < number[1] + len(number[0]) and number[1] != 0: point = number[1] break final_lines.append(rest_text[:point]) rest_text = rest_text[point:] line_width = self.get_font( query.pipeline_config['output']['long-text-processing']['font-path'] ).getlength(rest_text) if line_width < text_width: final_lines.append(rest_text) break else: continue # 准备画布 img = Image.new('RGBA', (width, max(280, len(final_lines) * 35 + 65)), (255, 255, 255, 255)) draw = ImageDraw.Draw(img, mode='RGBA') self.ap.logger.debug('正在绘制图片...') # 绘制正文 line_number = 0 offset_x = 20 offset_y = 30 for final_line in final_lines: draw.text( (offset_x, offset_y + 35 * line_number), final_line, fill=(0, 0, 0), font=self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']), ) # 遍历此行,检查是否有emoji idx_in_line = 0 for ch in final_line: # 检查字符占位宽 char_code = ord(ch) if char_code >= 127: idx_in_line += 1 else: idx_in_line += 0.5 line_number += 1 self.ap.logger.debug('正在保存图片...') img.save(save_as) return save_as ================================================ FILE: src/langbot/pkg/pipeline/longtext/strategy.py ================================================ from __future__ import annotations import abc import typing from ...core import app import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query preregistered_strategies: list[typing.Type[LongTextStrategy]] = [] def strategy_class( name: str, ) -> typing.Callable[[typing.Type[LongTextStrategy]], typing.Type[LongTextStrategy]]: """Long text processing strategy class decorator Args: name (str): Strategy name Returns: typing.Callable[[typing.Type[LongTextStrategy]], typing.Type[LongTextStrategy]]: Decorator """ def decorator(cls: typing.Type[LongTextStrategy]) -> typing.Type[LongTextStrategy]: assert issubclass(cls, LongTextStrategy) cls.name = name preregistered_strategies.append(cls) return cls return decorator class LongTextStrategy(metaclass=abc.ABCMeta): """Long text processing strategy abstract class""" name: str ap: app.Application def __init__(self, ap: app.Application): self.ap = ap async def initialize(self): pass @abc.abstractmethod async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]: """处理长文本 If the text length exceeds the threshold, this method will be called. Args: message (str): Message query (core_entities.Query): Query object Returns: list[platform_message.MessageComponent]: Converted platform message components """ return [] ================================================ FILE: src/langbot/pkg/pipeline/monitoring_helper.py ================================================ """ Monitoring helper for recording events during pipeline execution. This module provides convenient methods to record monitoring data without cluttering the main pipeline code. """ from __future__ import annotations import traceback import typing import time import json if typing.TYPE_CHECKING: from ..core import app import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query class MonitoringHelper: """Helper class for monitoring operations""" @staticmethod async def record_query_start( ap: app.Application, query: pipeline_query.Query, bot_id: str, bot_name: str, pipeline_id: str, pipeline_name: str, runner_name: str | None = None, ) -> str: """Record the start of query processing, returns message_id""" try: # Check if session exists, if not, record session start session_id = f'{query.launcher_type}_{query.launcher_id}' # Get sender name from message event sender_name = None if hasattr(query, 'message_event'): if hasattr(query.message_event, 'sender'): if hasattr(query.message_event.sender, 'nickname'): sender_name = query.message_event.sender.nickname elif hasattr(query.message_event.sender, 'member_name'): sender_name = query.message_event.sender.member_name # Try to record message # Use JSON serialization to preserve message chain structure (including image URLs, etc.) if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'): message_content = json.dumps(query.message_chain.model_dump(), ensure_ascii=False) else: message_content = str(query) # Variables will be updated in record_query_success after preproc stage sets them # Here we just record None, the full variables will be set when query completes message_id = await ap.monitoring_service.record_message( bot_id=bot_id, bot_name=bot_name, pipeline_id=pipeline_id, pipeline_name=pipeline_name, message_content=message_content, session_id=session_id, status='pending', level='info', platform=query.launcher_type.value if hasattr(query.launcher_type, 'value') else str(query.launcher_type), user_id=query.sender_id, user_name=sender_name, runner_name=runner_name, variables=None, # Will be updated in record_query_success ) # Update session activity or create new session if it doesn't exist # Always pass pipeline info to handle pipeline switches session_updated = await ap.monitoring_service.update_session_activity( session_id, pipeline_id=pipeline_id, pipeline_name=pipeline_name, ) if not session_updated: # Session doesn't exist, create it await ap.monitoring_service.record_session_start( session_id=session_id, bot_id=bot_id, bot_name=bot_name, pipeline_id=pipeline_id, pipeline_name=pipeline_name, platform=query.launcher_type.value if hasattr(query.launcher_type, 'value') else str(query.launcher_type), user_id=query.sender_id, user_name=sender_name, ) return message_id except Exception as e: ap.logger.error(f'Failed to record query start: {e}') return '' @staticmethod async def record_query_success( ap: app.Application, message_id: str, query: pipeline_query.Query | None = None, ): """Record successful query processing by updating message status and variables""" try: if message_id: # Serialize query.variables (filtering out internal variables) query_variables_str = None if query and hasattr(query, 'variables') and query.variables: filtered_vars = {k: v for k, v in query.variables.items() if not k.startswith('_')} if filtered_vars: try: query_variables_str = json.dumps(filtered_vars, ensure_ascii=False, default=str) except Exception: pass await ap.monitoring_service.update_message_status( message_id=message_id, status='success', variables=query_variables_str, ) except Exception as e: ap.logger.error(f'Failed to record query success: {e}') @staticmethod async def record_query_response( ap: app.Application, query: pipeline_query.Query, bot_id: str, bot_name: str, pipeline_id: str, pipeline_name: str, runner_name: str | None = None, ): """Record bot response message to monitoring""" try: session_id = f'{query.launcher_type}_{query.launcher_id}' # Get sender name from message event sender_name = None if hasattr(query, 'message_event'): if hasattr(query.message_event, 'sender'): if hasattr(query.message_event.sender, 'nickname'): sender_name = query.message_event.sender.nickname elif hasattr(query.message_event.sender, 'member_name'): sender_name = query.message_event.sender.member_name # Extract response content from resp_message_chain if hasattr(query, 'resp_message_chain') and query.resp_message_chain: # Serialize the last response message chain last_resp = query.resp_message_chain[-1] if hasattr(last_resp, 'model_dump'): message_content = json.dumps(last_resp.model_dump(), ensure_ascii=False) else: message_content = str(last_resp) elif hasattr(query, 'resp_messages') and query.resp_messages: last_resp = query.resp_messages[-1] if hasattr(last_resp, 'get_content_platform_message_chain'): chain = last_resp.get_content_platform_message_chain() if hasattr(chain, 'model_dump'): message_content = json.dumps(chain.model_dump(), ensure_ascii=False) else: message_content = str(chain) else: message_content = str(last_resp) else: return # No response to record await ap.monitoring_service.record_message( bot_id=bot_id, bot_name=bot_name, pipeline_id=pipeline_id, pipeline_name=pipeline_name, message_content=message_content, session_id=session_id, status='success', level='info', platform=query.launcher_type.value if hasattr(query.launcher_type, 'value') else str(query.launcher_type), user_id=query.sender_id, user_name=sender_name, runner_name=runner_name, role='assistant', ) except Exception as e: ap.logger.error(f'Failed to record query response: {e}') @staticmethod async def record_query_error( ap: app.Application, query: pipeline_query.Query, bot_id: str, bot_name: str, pipeline_id: str, pipeline_name: str, error: Exception, runner_name: str | None = None, ) -> str: """Record query processing error, returns message_id""" try: session_id = f'{query.launcher_type}_{query.launcher_id}' # Get sender name from message event sender_name = None if hasattr(query, 'message_event'): if hasattr(query.message_event, 'sender'): if hasattr(query.message_event.sender, 'nickname'): sender_name = query.message_event.sender.nickname elif hasattr(query.message_event.sender, 'member_name'): sender_name = query.message_event.sender.member_name # Record error message message_id = await ap.monitoring_service.record_message( bot_id=bot_id, bot_name=bot_name, pipeline_id=pipeline_id, pipeline_name=pipeline_name, message_content=f'Error: {str(error)}', session_id=session_id, status='error', level='error', platform=query.launcher_type.value if hasattr(query.launcher_type, 'value') else str(query.launcher_type), user_id=query.sender_id, user_name=sender_name, runner_name=runner_name, ) # Record error log await ap.monitoring_service.record_error( bot_id=bot_id, bot_name=bot_name, pipeline_id=pipeline_id, pipeline_name=pipeline_name, error_type=type(error).__name__, error_message=str(error), session_id=session_id, stack_trace=traceback.format_exc(), message_id=message_id, ) return message_id except Exception as e: ap.logger.error(f'Failed to record query error: {e}') return '' @staticmethod async def record_llm_call( ap: app.Application, query: pipeline_query.Query, bot_id: str, bot_name: str, pipeline_id: str, pipeline_name: str, model_name: str, input_tokens: int, output_tokens: int, duration_ms: int, status: str = 'success', cost: float | None = None, error_message: str | None = None, message_id: str | None = None, ): """Record LLM call""" try: session_id = f'{query.launcher_type}_{query.launcher_id}' await ap.monitoring_service.record_llm_call( bot_id=bot_id, bot_name=bot_name, pipeline_id=pipeline_id, pipeline_name=pipeline_name, session_id=session_id, model_name=model_name, input_tokens=input_tokens, output_tokens=output_tokens, duration=duration_ms, status=status, cost=cost, error_message=error_message, message_id=message_id, ) except Exception as e: ap.logger.error(f'Failed to record LLM call: {e}') class LLMCallMonitor: """Context manager for monitoring LLM calls""" def __init__( self, ap: app.Application, query: pipeline_query.Query, bot_id: str, bot_name: str, pipeline_id: str, pipeline_name: str, model_name: str, ): self.ap = ap self.query = query self.bot_id = bot_id self.bot_name = bot_name self.pipeline_id = pipeline_id self.pipeline_name = pipeline_name self.model_name = model_name self.start_time = None self.input_tokens = 0 self.output_tokens = 0 async def __aenter__(self): self.start_time = time.time() return self async def __aexit__(self, exc_type, exc_val, exc_tb): duration_ms = int((time.time() - self.start_time) * 1000) if exc_type is not None: # Error occurred await MonitoringHelper.record_llm_call( ap=self.ap, query=self.query, bot_id=self.bot_id, bot_name=self.bot_name, pipeline_id=self.pipeline_id, pipeline_name=self.pipeline_name, model_name=self.model_name, input_tokens=self.input_tokens, output_tokens=self.output_tokens, duration_ms=duration_ms, status='error', error_message=str(exc_val) if exc_val else None, ) else: # Success await MonitoringHelper.record_llm_call( ap=self.ap, query=self.query, bot_id=self.bot_id, bot_name=self.bot_name, pipeline_id=self.pipeline_id, pipeline_name=self.pipeline_name, model_name=self.model_name, input_tokens=self.input_tokens, output_tokens=self.output_tokens, duration_ms=duration_ms, status='success', ) return False # Don't suppress exceptions ================================================ FILE: src/langbot/pkg/pipeline/msgtrun/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/msgtrun/msgtrun.py ================================================ from __future__ import annotations from .. import stage, entities from . import truncator from ...utils import importutil import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query from . import truncators importutil.import_modules_in_pkg(truncators) @stage.stage_class('ConversationMessageTruncator') class ConversationMessageTruncator(stage.PipelineStage): """Conversation message truncator Used to truncate the conversation message chain to adapt to the LLM message length limit. """ trun: truncator.Truncator async def initialize(self, pipeline_config: dict): use_method = 'round' for trun in truncator.preregistered_truncators: if trun.name == use_method: self.trun = trun(self.ap) break else: raise ValueError(f'Unknown truncator: {use_method}') async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: """处理""" query = await self.trun.truncate(query) return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) ================================================ FILE: src/langbot/pkg/pipeline/msgtrun/truncator.py ================================================ from __future__ import annotations import typing import abc from ...core import app import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query preregistered_truncators: list[typing.Type[Truncator]] = [] def truncator_class( name: str, ) -> typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: """截断器类装饰器 Args: name (str): 截断器名称 Returns: typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: 装饰器 """ def decorator(cls: typing.Type[Truncator]) -> typing.Type[Truncator]: assert issubclass(cls, Truncator) cls.name = name preregistered_truncators.append(cls) return cls return decorator class Truncator(abc.ABC): """消息截断器基类""" name: str ap: app.Application def __init__(self, ap: app.Application): self.ap = ap async def initialize(self): pass @abc.abstractmethod async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query: """截断 一般只需要操作query.messages,也可以扩展操作query.prompt, query.user_message。 请勿操作其他字段。 """ pass ================================================ FILE: src/langbot/pkg/pipeline/msgtrun/truncators/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/msgtrun/truncators/round.py ================================================ from __future__ import annotations from .. import truncator import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @truncator.truncator_class('round') class RoundTruncator(truncator.Truncator): """Truncate the conversation message chain to adapt to the LLM message length limit.""" async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query: """截断""" max_round = query.pipeline_config['ai']['local-agent']['max-round'] temp_messages = [] current_round = 0 # Traverse from back to front for msg in query.messages[::-1]: if current_round < max_round: temp_messages.append(msg) if msg.role == 'user': current_round += 1 else: break query.messages = temp_messages[::-1] return query ================================================ FILE: src/langbot/pkg/pipeline/pipelinemgr.py ================================================ from __future__ import annotations import typing import traceback import sqlalchemy from ..core import app from . import entities as pipeline_entities from ..entity.persistence import pipeline as persistence_pipeline from . import stage import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.events as events from ..utils import importutil from .config_coercion import coerce_pipeline_config import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query from . import ( resprule, bansess, cntfilter, process, longtext, respback, wrapper, preproc, ratelimit, msgtrun, ) importutil.import_modules_in_pkgs( [ resprule, bansess, cntfilter, process, longtext, respback, wrapper, preproc, ratelimit, msgtrun, ] ) class StageInstContainer: """阶段实例容器""" inst_name: str inst: stage.PipelineStage def __init__(self, inst_name: str, inst: stage.PipelineStage): self.inst_name = inst_name self.inst = inst class RuntimePipeline: """运行时流水线""" ap: app.Application pipeline_entity: persistence_pipeline.LegacyPipeline """流水线实体""" stage_containers: list[StageInstContainer] """阶段实例容器""" bound_plugins: list[str] | None """绑定到此流水线的插件列表(格式:author/plugin_name),None表示启用所有""" bound_mcp_servers: list[str] | None """绑定到此流水线的MCP服务器列表(格式:uuid),None表示启用所有""" enable_all_plugins: bool """是否启用所有插件""" enable_all_mcp_servers: bool """是否启用所有MCP服务器""" def __init__( self, ap: app.Application, pipeline_entity: persistence_pipeline.LegacyPipeline, stage_containers: list[StageInstContainer], ): self.ap = ap self.pipeline_entity = pipeline_entity self.stage_containers = stage_containers # Extract bound plugins and MCP servers from extensions_preferences extensions_prefs = pipeline_entity.extensions_preferences or {} self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True) self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True) if self.enable_all_plugins: # None indicates to use all available plugins self.bound_plugins = None else: plugin_list = extensions_prefs.get('plugins', []) self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list] if plugin_list else [] if self.enable_all_mcp_servers: # None indicates to use all available MCP servers self.bound_mcp_servers = None else: mcp_server_list = extensions_prefs.get('mcp_servers', []) self.bound_mcp_servers = mcp_server_list if mcp_server_list else [] async def run(self, query: pipeline_query.Query): query.pipeline_config = self.pipeline_entity.config # Store bound plugins and MCP servers in query for filtering query.variables['_pipeline_bound_plugins'] = self.bound_plugins query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers # Record query start for monitoring try: # Get bot name from bot_uuid bot_name = 'WebChat' if query.bot_uuid: try: bot = await self.ap.bot_service.get_bot(query.bot_uuid, include_secret=False) if bot: bot_name = bot.get('name', 'Unknown') except Exception: pass # Store for later use in process_query query.variables['_monitoring_bot_name'] = bot_name query.variables['_monitoring_pipeline_name'] = self.pipeline_entity.name except Exception as e: self.ap.logger.error(f'Failed to prepare monitoring data: {e}') await self.process_query(query) async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult): """检查输出""" if result.user_notice: # 处理str类型 if isinstance(result.user_notice, str): result.user_notice = platform_message.MessageChain([platform_message.Plain(text=result.user_notice)]) elif isinstance(result.user_notice, list): result.user_notice = platform_message.MessageChain(*result.user_notice) if query.pipeline_config['output']['misc']['at-sender'] and isinstance( query.message_event, platform_events.GroupMessage ): result.user_notice.insert(0, platform_message.At(target=query.message_event.sender.id)) if await query.adapter.is_stream_output_supported() and query.resp_messages: await query.adapter.reply_message_chunk( message_source=query.message_event, bot_message=query.resp_messages[-1], message=result.user_notice, quote_origin=query.pipeline_config['output']['misc']['quote-origin'], is_final=[msg.is_final for msg in query.resp_messages][0], ) else: await query.adapter.reply_message( message_source=query.message_event, message=result.user_notice, quote_origin=query.pipeline_config['output']['misc']['quote-origin'], ) if result.debug_notice: self.ap.logger.debug(result.debug_notice) if result.console_notice: self.ap.logger.info(result.console_notice) if result.error_notice: self.ap.logger.error(result.error_notice) # Mark query as having error query.variables['_monitoring_has_error'] = True # Record error to monitoring system try: bot_name = query.variables.get('_monitoring_bot_name', 'Unknown') pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown') message_id = query.variables.get('_monitoring_message_id', '') session_id = f'{query.launcher_type}_{query.launcher_id}' # Update message status to error if message_id: await self.ap.monitoring_service.update_message_status( message_id=message_id, status='error', level='error', ) # Record error log await self.ap.monitoring_service.record_error( bot_id=query.bot_uuid or 'unknown', bot_name=bot_name, pipeline_id=self.pipeline_entity.uuid, pipeline_name=pipeline_name, error_type='PipelineError', error_message=result.error_notice, session_id=session_id, stack_trace=result.debug_notice if result.debug_notice else None, message_id=message_id, ) except Exception as e: self.ap.logger.error(f'Failed to record error to monitoring: {e}') async def _execute_from_stage( self, stage_index: int, query: pipeline_query.Query, ): """从指定阶段开始执行,实现了责任链模式和基于生成器的阶段分叉功能。 如何看懂这里为什么这么写? 去问 GPT-4: Q1: 现在有一个责任链,其中有多个stage,query对象在其中传递,stage.process可能返回Result也有可能返回typing.AsyncGenerator[Result, None], 如果返回的是生成器,需要挨个生成result,检查是否result中是否要求继续,如果要求继续就进行下一个stage。如果此次生成器产生的result处理完了,就继续生成下一个result, 调用后续的stage,直到该生成器全部生成完。责任链中可能有多个stage会返回生成器 Q2: 不是这样的,你可能理解有误。如果我们责任链上有这些Stage: A B C D E F G 如果所有的stage都返回Result,且所有Result都要求继续,那么执行顺序是: A B C D E F G 现在假设C返回的是AsyncGenerator,那么执行顺序是: A B C D E F G C D E F G C D E F G ... Q3: 但是如果不止一个stage会返回生成器呢? """ i = stage_index while i < len(self.stage_containers): stage_container = self.stage_containers[i] query.current_stage_name = stage_container.inst_name # 标记到 Query 对象里 result = stage_container.inst.process(query, stage_container.inst_name) if isinstance(result, typing.Coroutine): result = await result if isinstance(result, pipeline_entities.StageProcessResult): # 直接返回结果 self.ap.logger.debug( f'Stage {stage_container.inst_name} processed query {query.query_id} res {result.result_type}' ) await self._check_output(query, result) if result.result_type == pipeline_entities.ResultType.INTERRUPT: self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}') break elif result.result_type == pipeline_entities.ResultType.CONTINUE: query = result.new_query elif isinstance(result, typing.AsyncGenerator): # 生成器 self.ap.logger.debug(f'Stage {stage_container.inst_name} processed query {query.query_id} gen') async for sub_result in result: self.ap.logger.debug( f'Stage {stage_container.inst_name} processed query {query.query_id} res {sub_result.result_type}' ) await self._check_output(query, sub_result) if sub_result.result_type == pipeline_entities.ResultType.INTERRUPT: self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}') break elif sub_result.result_type == pipeline_entities.ResultType.CONTINUE: query = sub_result.new_query await self._execute_from_stage(i + 1, query) break i += 1 async def process_query(self, query: pipeline_query.Query): """处理请求""" # Get monitoring metadata bot_name = query.variables.get('_monitoring_bot_name', 'Unknown') pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown') # Get runner name from pipeline config runner_name = None if query.pipeline_config and 'ai' in query.pipeline_config and 'runner' in query.pipeline_config['ai']: runner_name = query.pipeline_config['ai']['runner'].get('runner') # Record query start and store message_id message_id = '' try: from . import monitoring_helper message_id = await monitoring_helper.MonitoringHelper.record_query_start( ap=self.ap, query=query, bot_id=query.bot_uuid or 'unknown', bot_name=bot_name, pipeline_id=self.pipeline_entity.uuid, pipeline_name=pipeline_name, runner_name=runner_name, ) # Store message_id in query variables for LLM call monitoring query.variables['_monitoring_message_id'] = message_id except Exception as e: self.ap.logger.error(f'Failed to record query start: {e}') try: # Get bound plugins for this pipeline bound_plugins = query.variables.get('_pipeline_bound_plugins', None) # ======== 触发 MessageReceived 事件 ======== event_type = ( events.PersonMessageReceived if query.launcher_type == provider_session.LauncherTypes.PERSON else events.GroupMessageReceived ) event_obj = event_type( query=query, launcher_type=query.launcher_type.value, launcher_id=query.launcher_id, sender_id=query.sender_id, message_event=query.message_event, message_chain=query.message_chain, ) event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins) if event_ctx.is_prevented_default(): return self.ap.logger.debug(f'Processing query {query.query_id}') await self._execute_from_stage(0, query) # Record query success only if no error occurred during processing if not query.variables.get('_monitoring_has_error', False): try: await monitoring_helper.MonitoringHelper.record_query_success( ap=self.ap, message_id=message_id, query=query, ) except Exception as e: self.ap.logger.error(f'Failed to record query success: {e}') # Record bot response message try: await monitoring_helper.MonitoringHelper.record_query_response( ap=self.ap, query=query, bot_id=query.bot_uuid or 'unknown', bot_name=bot_name, pipeline_id=self.pipeline_entity.uuid, pipeline_name=pipeline_name, runner_name=runner_name, ) except Exception as e: self.ap.logger.error(f'Failed to record query response: {e}') except Exception as e: inst_name = query.current_stage_name if query.current_stage_name else 'unknown' self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}') self.ap.logger.error(f'Traceback: {traceback.format_exc()}') # Record query error try: from . import monitoring_helper await monitoring_helper.MonitoringHelper.record_query_error( ap=self.ap, query=query, bot_id=query.bot_uuid or 'unknown', bot_name=bot_name, pipeline_id=self.pipeline_entity.uuid, pipeline_name=pipeline_name, error=e, runner_name=runner_name, ) except Exception as me: self.ap.logger.error(f'Failed to record query error: {me}') finally: self.ap.logger.debug(f'Query {query.query_id} processed') del self.ap.query_pool.cached_queries[query.query_id] class PipelineManager: """流水线管理器""" ap: app.Application pipelines: list[RuntimePipeline] stage_dict: dict[str, type[stage.PipelineStage]] def __init__(self, ap: app.Application): self.ap = ap self.pipelines = [] async def initialize(self): self.stage_dict = {name: cls for name, cls in stage.preregistered_stages.items()} await self.load_pipelines_from_db() async def load_pipelines_from_db(self): self.ap.logger.info('Loading pipelines from db...') result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) pipelines = result.all() # load pipelines for pipeline in pipelines: await self.load_pipeline(pipeline) async def load_pipeline( self, pipeline_entity: persistence_pipeline.LegacyPipeline | sqlalchemy.Row[persistence_pipeline.LegacyPipeline] | dict, ): if isinstance(pipeline_entity, sqlalchemy.Row): pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity._mapping) elif isinstance(pipeline_entity, dict): pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity) coerce_pipeline_config( pipeline_entity.config, getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}), getattr(self.ap, 'pipeline_config_meta_safety', {'name': 'safety', 'stages': []}), getattr(self.ap, 'pipeline_config_meta_ai', {'name': 'ai', 'stages': []}), getattr(self.ap, 'pipeline_config_meta_output', {'name': 'output', 'stages': []}), ) # initialize stage containers according to pipeline_entity.stages stage_containers: list[StageInstContainer] = [] for stage_name in pipeline_entity.stages: stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap))) for stage_container in stage_containers: await stage_container.inst.initialize(pipeline_entity.config) runtime_pipeline = RuntimePipeline(self.ap, pipeline_entity, stage_containers) self.pipelines.append(runtime_pipeline) async def get_pipeline_by_uuid(self, uuid: str) -> RuntimePipeline | None: for pipeline in self.pipelines: if pipeline.pipeline_entity.uuid == uuid: return pipeline return None async def remove_pipeline(self, uuid: str): for pipeline in self.pipelines: if pipeline.pipeline_entity.uuid == uuid: self.pipelines.remove(pipeline) return ================================================ FILE: src/langbot/pkg/pipeline/pool.py ================================================ from __future__ import annotations import asyncio import typing import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter class QueryPool: """请求池,请求获得调度进入pipeline之前,保存在这里""" query_id_counter: int = 0 pool_lock: asyncio.Lock queries: list[pipeline_query.Query] cached_queries: dict[int, pipeline_query.Query] """Cached queries, used for plugin backward api call, will be removed after the query completely processed""" condition: asyncio.Condition def __init__(self): self.query_id_counter = 0 self.pool_lock = asyncio.Lock() self.queries = [] self.cached_queries = {} self.condition = asyncio.Condition(self.pool_lock) async def add_query( self, bot_uuid: str, launcher_type: provider_session.LauncherTypes, launcher_id: typing.Union[int, str], sender_id: typing.Union[int, str], message_event: platform_events.MessageEvent, message_chain: platform_message.MessageChain, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, pipeline_uuid: typing.Optional[str] = None, ) -> pipeline_query.Query: async with self.condition: query_id = self.query_id_counter query = pipeline_query.Query( bot_uuid=bot_uuid, query_id=query_id, launcher_type=launcher_type, launcher_id=launcher_id, sender_id=sender_id, message_event=message_event, message_chain=message_chain, variables={}, resp_messages=[], resp_message_chain=[], adapter=adapter, pipeline_uuid=pipeline_uuid, ) self.queries.append(query) self.cached_queries[query_id] = query self.query_id_counter += 1 self.condition.notify_all() async def __aenter__(self): await self.pool_lock.acquire() return self async def __aexit__(self, exc_type, exc_val, exc_tb): self.pool_lock.release() ================================================ FILE: src/langbot/pkg/pipeline/preproc/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/preproc/preproc.py ================================================ from __future__ import annotations import datetime from .. import stage, entities from langbot_plugin.api.entities.builtin.provider import message as provider_message import langbot_plugin.api.entities.events as events import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.platform.events as platform_events @stage.stage_class('PreProcessor') class PreProcessor(stage.PipelineStage): """Request pre-processing stage Check out session, prompt, context, model, and content functions. Rewrite: - session - prompt - messages - user_message - use_model - use_funcs """ async def process( self, query: pipeline_query.Query, stage_inst_name: str, ) -> entities.StageProcessResult: """Process""" selected_runner = query.pipeline_config['ai']['runner']['runner'] session = await self.ap.sess_mgr.get_session(query) # When not local-agent, llm_model is None llm_model = None if selected_runner == 'local-agent': # Read model config — new format is { primary: str, fallbacks: [str] }, # but handle legacy plain string for backward compatibility model_config = query.pipeline_config['ai']['local-agent'].get('model', {}) if isinstance(model_config, str): # Legacy format: plain UUID string primary_uuid = model_config fallback_uuids = [] else: primary_uuid = model_config.get('primary', '') fallback_uuids = model_config.get('fallbacks', []) if primary_uuid: try: llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid) except ValueError: self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured') # Resolve fallback model UUIDs if fallback_uuids: valid_fallbacks = [] for fb_uuid in fallback_uuids: try: await self.ap.model_mgr.get_model_by_uuid(fb_uuid) valid_fallbacks.append(fb_uuid) except ValueError: self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping') if valid_fallbacks: query.variables['_fallback_model_uuids'] = valid_fallbacks conversation = await self.ap.sess_mgr.get_conversation( query, session, query.pipeline_config['ai']['local-agent']['prompt'], query.pipeline_uuid, query.bot_uuid, ) # 设置query query.session = session query.prompt = conversation.prompt.copy() query.messages = conversation.messages.copy() if selected_runner == 'local-agent': query.use_funcs = [] if llm_model: query.use_llm_model_uuid = llm_model.model_entity.uuid if llm_model.model_entity.abilities.__contains__('func_call'): # Get bound plugins and MCP servers for filtering tools bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers) self.ap.logger.debug(f'Bound plugins: {bound_plugins}') self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') self.ap.logger.debug(f'Use funcs: {query.use_funcs}') # If primary model doesn't support func_call but fallback models exist, # load tools anyway since fallback models may support them if not query.use_funcs and query.variables.get('_fallback_model_uuids'): bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers) sender_name = '' if isinstance(query.message_event, platform_events.GroupMessage): sender_name = query.message_event.sender.member_name elif isinstance(query.message_event, platform_events.FriendMessage): sender_name = query.message_event.sender.nickname variables = { 'launcher_type': query.session.launcher_type.value, 'launcher_id': query.session.launcher_id, 'sender_id': query.sender_id, 'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}', 'conversation_id': conversation.uuid, 'msg_create_time': ( int(query.message_event.time) if query.message_event.time else int(datetime.datetime.now().timestamp()) ), 'group_name': query.message_event.group.name if isinstance(query.message_event, platform_events.GroupMessage) else '', 'sender_name': sender_name, } query.variables.update(variables) # Check if this model supports vision, if not, remove all images # TODO this checking should be performed in runner, and in this stage, the image should be reserved if ( selected_runner == 'local-agent' and llm_model and not llm_model.model_entity.abilities.__contains__('vision') ): for msg in query.messages: if isinstance(msg.content, list): for me in msg.content: if me.type == 'image_url': msg.content.remove(me) content_list: list[provider_message.ContentElement] = [] plain_text = '' quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message') for me in query.message_chain: if isinstance(me, platform_message.Plain): content_list.append(provider_message.ContentElement.from_text(me.text)) plain_text += me.text elif isinstance(me, platform_message.Image): if selected_runner != 'local-agent' or ( llm_model and llm_model.model_entity.abilities.__contains__('vision') ): if me.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(me.base64)) elif isinstance(me, platform_message.Voice): # 转成文件链接,让下游 runner 上传到目标模型 if me.base64: content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk')) elif me.url: content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice')) elif isinstance(me, platform_message.File): # if me.url is not None: content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name)) elif isinstance(me, platform_message.Quote) and quote_msg: for msg in me.origin: if isinstance(msg, platform_message.Plain): content_list.append(provider_message.ContentElement.from_text(msg.text)) elif isinstance(msg, platform_message.Image): if selected_runner != 'local-agent' or ( llm_model and llm_model.model_entity.abilities.__contains__('vision') ): if msg.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(msg.base64)) query.variables['user_message_text'] = plain_text query.user_message = provider_message.Message(role='user', content=content_list) # Extract knowledge base UUIDs into query variables so plugins can modify them # during PromptPreProcessing before the runner performs retrieval. kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', []) if not kb_uuids: old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '') if old_kb_uuid and old_kb_uuid != '__none__': kb_uuids = [old_kb_uuid] query.variables['_knowledge_base_uuids'] = list(kb_uuids) # =========== 触发事件 PromptPreProcessing event = events.PromptPreProcessing( session_name=f'{query.session.launcher_type.value}_{query.session.launcher_id}', default_prompt=query.prompt.messages, prompt=query.messages, query=query, ) # Get bound plugins for filtering bound_plugins = query.variables.get('_pipeline_bound_plugins', None) event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) query.prompt.messages = event_ctx.event.default_prompt query.messages = event_ctx.event.prompt return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) ================================================ FILE: src/langbot/pkg/pipeline/process/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/process/handler.py ================================================ from __future__ import annotations import abc from ...core import app from .. import entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query class MessageHandler(metaclass=abc.ABCMeta): ap: app.Application def __init__(self, ap: app.Application): self.ap = ap async def initialize(self): pass @abc.abstractmethod async def handle( self, query: pipeline_query.Query, ) -> entities.StageProcessResult: raise NotImplementedError def cut_str(self, s: str) -> str: """ Take the first line of the string, up to 20 characters, if there are multiple lines, or more than 20 characters, add an ellipsis """ s0 = s.split('\n')[0] if len(s0) > 20 or '\n' in s: s0 = s0[:20] + '...' return s0 ================================================ FILE: src/langbot/pkg/pipeline/process/handlers/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/process/handlers/chat.py ================================================ from __future__ import annotations import uuid import typing import traceback import time from datetime import datetime from .. import handler from ... import entities from ....provider import runner as runner_module import langbot_plugin.api.entities.events as events from ....utils import importutil, constants, runner as runner_utils from ....provider import runners import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.provider.message as provider_message importutil.import_modules_in_pkg(runners) class ChatMessageHandler(handler.MessageHandler): async def handle( self, query: pipeline_query.Query, ) -> typing.AsyncGenerator[entities.StageProcessResult, None]: """处理""" # 调API # 生成器 # 触发插件事件 event_class = ( events.PersonNormalMessageReceived if query.launcher_type == provider_session.LauncherTypes.PERSON else events.GroupNormalMessageReceived ) event = event_class( launcher_type=query.launcher_type.value, launcher_id=query.launcher_id, sender_id=query.sender_id, text_message=str(query.message_chain), message_event=query.message_event, message_chain=query.message_chain, query=query, ) # Get bound plugins for filtering bound_plugins = query.variables.get('_pipeline_bound_plugins', None) event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) is_create_card = False # 判断下是否需要创建流式卡片 if event_ctx.is_prevented_default(): if event_ctx.event.reply_message_chain is not None: mc = event_ctx.event.reply_message_chain query.resp_messages.append(mc) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) else: yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) else: if event_ctx.event.user_message_alter is not None: if isinstance(event_ctx.event.user_message_alter, list): query.user_message.content = event_ctx.event.user_message_alter elif isinstance(event_ctx.event.user_message_alter, str): query.user_message.content = [ provider_message.ContentElement.from_text(event_ctx.event.user_message_alter) ] elif isinstance(event_ctx.event.user_message_alter, provider_message.ContentElement): query.user_message.content = [event_ctx.event.user_message_alter] text_length = 0 try: is_stream = await query.adapter.is_stream_output_supported() except AttributeError: is_stream = False try: for r in runner_module.preregistered_runners: if r.name == query.pipeline_config['ai']['runner']['runner']: runner = r(self.ap, query.pipeline_config) break else: raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}') # Mark start time for telemetry start_ts = time.time() if is_stream: resp_message_id = uuid.uuid4() chunk_count = 0 # Track streaming chunks to reduce excessive logging async for result in runner.run(query): result.resp_message_id = str(resp_message_id) if query.resp_messages: query.resp_messages.pop() if query.resp_message_chain: query.resp_message_chain.pop() # 此时连接外部 AI 服务正常,创建卡片 if not is_create_card: # 只有不是第一次才创建卡片 await query.adapter.create_message_card(str(resp_message_id), query.message_event) is_create_card = True query.resp_messages.append(result) chunk_count += 1 # Only log every 10th chunk to reduce excessive logging during streaming # This prevents memory overflow from thousands of log entries per conversation # First chunk uses INFO level to confirm connection establishment if chunk_count == 1: self.ap.logger.info( f'Conversation({query.query_id}) Streaming started: {self.cut_str(result.readable_str())}' ) elif chunk_count % 10 == 0: self.ap.logger.debug( f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}' ) if result.content is not None: text_length += len(result.content) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) # Log final summary after streaming completes self.ap.logger.info( f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars' ) else: async for result in runner.run(query): query.resp_messages.append(result) self.ap.logger.info( f'Conversation({query.query_id}) Response: {self.cut_str(result.readable_str())}' ) if result.content is not None: text_length += len(result.content) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) query.session.using_conversation.messages.append(query.user_message) query.session.using_conversation.messages.extend(query.resp_messages) except Exception as e: error_info = f'{traceback.format_exc()}' self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}') traceback.print_exc() exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint') if exception_handling == 'show-error': user_notice = f'{e}' elif exception_handling == 'show-hint': user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.') else: # hide user_notice = None yield entities.StageProcessResult( result_type=entities.ResultType.INTERRUPT, new_query=query, user_notice=user_notice, error_notice=f'{e}', debug_notice=traceback.format_exc(), ) finally: # Telemetry reporting: collect minimal per-query execution info and send asynchronously try: end_ts = time.time() duration_ms = None if 'start_ts' in locals(): duration_ms = int((end_ts - start_ts) * 1000) adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None runner_name = ( query.pipeline_config.get('ai', {}).get('runner', {}).get('runner') if query.pipeline_config else None ) # Model name if using localagent model_name = None try: if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None): m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid) if m and getattr(m, 'model_entity', None): model_name = getattr(m.model_entity, 'name', None) except Exception: model_name = None pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None) runner_category = runner_utils.get_runner_category_from_runner( runner_name, runner, query.pipeline_config ) payload = { 'query_id': query.query_id, 'adapter': adapter_name, 'runner': runner_name, 'runner_category': runner_category, 'duration_ms': duration_ms, 'model_name': model_name, 'version': constants.semantic_version, 'instance_id': constants.instance_id, 'pipeline_plugins': pipeline_plugins, 'error': locals().get('error_info', None), 'timestamp': datetime.utcnow().isoformat(), } # Send telemetry asynchronously and do not block pipeline via app's telemetry manager await self.ap.telemetry.start_send_task(payload) # Trigger survey event on first successful non-WebSocket response if not locals().get('error_info') and adapter_name and 'WebSocket' not in adapter_name: if self.ap.survey: await self.ap.survey.trigger_event('first_bot_response_success') except Exception as ex: # Ensure telemetry issues do not affect normal flow self.ap.logger.warning(f'Failed to send telemetry: {ex}') ================================================ FILE: src/langbot/pkg/pipeline/process/handlers/command.py ================================================ from __future__ import annotations import typing from .. import handler from ... import entities import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.events as events class CommandHandler(handler.MessageHandler): async def handle( self, query: pipeline_query.Query, ) -> typing.AsyncGenerator[entities.StageProcessResult, None]: """Process""" full_command_text = str(query.message_chain).strip() command_text = full_command_text[1:] privilege = 1 if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']: privilege = 2 spt = command_text.split(' ') event_class = ( events.PersonCommandSent if query.launcher_type == provider_session.LauncherTypes.PERSON else events.GroupCommandSent ) event = event_class( launcher_type=query.launcher_type.value, launcher_id=query.launcher_id, sender_id=query.sender_id, command=spt[0], params=spt[1:] if len(spt) > 1 else [], text_message=full_command_text, is_admin=(privilege == 2), query=query, ) # Get bound plugins for filtering bound_plugins = query.variables.get('_pipeline_bound_plugins', None) event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) if event_ctx.is_prevented_default(): if event_ctx.event.reply_message_chain is not None: mc = event_ctx.event.reply_message_chain query.resp_messages.append(mc) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) else: yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) else: session = await self.ap.sess_mgr.get_session(query) async for ret in self.ap.cmd_mgr.execute( command_text=command_text, full_command_text=full_command_text, query=query, session=session ): if ret.error is not None: query.resp_messages.append( provider_message.Message( role='command', content=str(ret.error), ) ) self.ap.logger.info(f'Command({query.query_id}) error: {self.cut_str(str(ret.error))}') yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) elif ( ret.text is not None or ret.image_url is not None or ret.image_base64 is not None or ret.file_url is not None ): content: list[provider_message.ContentElement] = [] if ret.text is not None: content.append(provider_message.ContentElement.from_text(ret.text)) if ret.image_url is not None: content.append(provider_message.ContentElement.from_image_url(ret.image_url)) if ret.image_base64 is not None: content.append(provider_message.ContentElement.from_image_base64(ret.image_base64)) if ret.file_url is not None: # 此时为 file 类型 content.append(provider_message.ContentElement.from_file_url(ret.file_url, ret.file_name)) query.resp_messages.append( provider_message.Message( role='command', content=content, ) ) self.ap.logger.info(f'Command returned: {self.cut_str(str(content[0]))}') yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) else: yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) ================================================ FILE: src/langbot/pkg/pipeline/process/process.py ================================================ from __future__ import annotations from . import handler from .handlers import chat, command from .. import entities from .. import stage import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @stage.stage_class('MessageProcessor') class Processor(stage.PipelineStage): """请求实际处理阶段 通过命令处理器和聊天处理器处理消息。 改写: - resp_messages """ cmd_handler: handler.MessageHandler chat_handler: handler.MessageHandler async def initialize(self, pipeline_config: dict): self.cmd_handler = command.CommandHandler(self.ap) self.chat_handler = chat.ChatMessageHandler(self.ap) await self.cmd_handler.initialize() await self.chat_handler.initialize() async def process( self, query: pipeline_query.Query, stage_inst_name: str, ) -> entities.StageProcessResult: """Process""" message_text = str(query.message_chain).strip() self.ap.logger.info( f'Processing request from {query.launcher_type.value}_{query.launcher_id} ({query.query_id}): {message_text}' ) async def generator(): cmd_prefix = self.ap.instance_config.data['command']['prefix'] cmd_enable = self.ap.instance_config.data['command'].get('enable', True) if cmd_enable and any(message_text.startswith(prefix) for prefix in cmd_prefix): handler_to_use = self.cmd_handler else: handler_to_use = self.chat_handler async for result in handler_to_use.handle(query): yield result return generator() ================================================ FILE: src/langbot/pkg/pipeline/ratelimit/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/ratelimit/algo.py ================================================ from __future__ import annotations import abc import typing from ...core import app import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query preregistered_algos: list[typing.Type[ReteLimitAlgo]] = [] def algo_class(name: str): def decorator(cls: typing.Type[ReteLimitAlgo]) -> typing.Type[ReteLimitAlgo]: cls.name = name preregistered_algos.append(cls) return cls return decorator class ReteLimitAlgo(metaclass=abc.ABCMeta): """限流算法抽象类""" name: str = None ap: app.Application def __init__(self, ap: app.Application): self.ap = ap async def initialize(self): pass @abc.abstractmethod async def require_access( self, query: pipeline_query.Query, launcher_type: str, launcher_id: typing.Union[int, str], ) -> bool: """进入处理流程 这个方法对等待是友好的,意味着算法可以实现在这里等待一段时间以控制速率。 Args: launcher_type (str): 请求者类型 群聊为 group 私聊为 person launcher_id (int): 请求者ID Returns: bool: 是否允许进入处理流程,若返回false,则直接丢弃该请求 """ raise NotImplementedError @abc.abstractmethod async def release_access( self, query: pipeline_query.Query, launcher_type: str, launcher_id: typing.Union[int, str], ): """退出处理流程 Args: launcher_type (str): 请求者类型 群聊为 group 私聊为 person launcher_id (int): 请求者ID """ raise NotImplementedError ================================================ FILE: src/langbot/pkg/pipeline/ratelimit/algos/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/ratelimit/algos/fixedwin.py ================================================ from __future__ import annotations import asyncio import time import typing from .. import algo import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query # 固定窗口算法 class SessionContainer: wait_lock: asyncio.Lock records: dict[int, int] """访问记录,key为每窗口长度的起始时间戳,value为访问次数""" def __init__(self): self.wait_lock = asyncio.Lock() self.records = {} @algo.algo_class('fixwin') class FixedWindowAlgo(algo.ReteLimitAlgo): containers_lock: asyncio.Lock """访问记录容器锁""" containers: dict[str, SessionContainer] """访问记录容器,key为launcher_type launcher_id""" async def initialize(self): self.containers_lock = asyncio.Lock() self.containers = {} async def require_access( self, query: pipeline_query.Query, launcher_type: str, launcher_id: typing.Union[int, str], ) -> bool: # 加锁,找容器 container: SessionContainer = None session_name = f'{launcher_type}_{launcher_id}' async with self.containers_lock: container = self.containers.get(session_name) if container is None: container = SessionContainer() self.containers[session_name] = container # 等待锁 async with container.wait_lock: # 获取窗口大小和限制 window_size = query.pipeline_config['safety']['rate-limit']['window-length'] limitation = query.pipeline_config['safety']['rate-limit']['limitation'] # TODO revert it # if session_name in self.ap.pipeline_cfg.data['rate-limit']['fixwin']: # window_size = self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name]['window-size'] # limitation = self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name]['limit'] # 获取当前时间戳 now = int(time.time()) # 获取当前窗口的起始时间戳 now = now - now % window_size # 获取当前窗口的访问次数 count = container.records.get(now, 0) # 如果访问次数超过了限制 if count >= limitation: if query.pipeline_config['safety']['rate-limit']['strategy'] == 'drop': return False elif query.pipeline_config['safety']['rate-limit']['strategy'] == 'wait': # 等待下一窗口 await asyncio.sleep(window_size - time.time() % window_size) now = int(time.time()) now = now - now % window_size if now not in container.records: container.records = {} container.records[now] = 1 else: # 访问次数加一 container.records[now] = count + 1 # 返回True return True async def release_access( self, query: pipeline_query.Query, launcher_type: str, launcher_id: typing.Union[int, str], ): pass ================================================ FILE: src/langbot/pkg/pipeline/ratelimit/ratelimit.py ================================================ from __future__ import annotations import typing from .. import entities, stage from . import algo from ...utils import importutil import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query from . import algos importutil.import_modules_in_pkg(algos) @stage.stage_class('RequireRateLimitOccupancy') @stage.stage_class('ReleaseRateLimitOccupancy') class RateLimit(stage.PipelineStage): """限速器控制阶段 不改写query,只检查是否需要限速。 """ algo: algo.ReteLimitAlgo async def initialize(self, pipeline_config: dict): algo_name = 'fixwin' algo_class = None for algo_cls in algo.preregistered_algos: if algo_cls.name == algo_name: algo_class = algo_cls break else: raise ValueError(f'未知的限速算法: {algo_name}') self.algo = algo_class(self.ap) await self.algo.initialize() async def process( self, query: pipeline_query.Query, stage_inst_name: str, ) -> typing.Union[ entities.StageProcessResult, typing.AsyncGenerator[entities.StageProcessResult, None], ]: """处理""" if stage_inst_name == 'RequireRateLimitOccupancy': if await self.algo.require_access( query, query.launcher_type.value, query.launcher_id, ): return entities.StageProcessResult( result_type=entities.ResultType.CONTINUE, new_query=query, ) else: return entities.StageProcessResult( result_type=entities.ResultType.INTERRUPT, new_query=query, console_notice=f'根据限速规则忽略 {query.launcher_type.value}:{query.launcher_id} 消息', user_notice='请求数超过限速器设定值,已丢弃本消息。', ) elif stage_inst_name == 'ReleaseRateLimitOccupancy': await self.algo.release_access( query, query.launcher_type.value, query.launcher_id, ) return entities.StageProcessResult( result_type=entities.ResultType.CONTINUE, new_query=query, ) ================================================ FILE: src/langbot/pkg/pipeline/respback/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/respback/respback.py ================================================ from __future__ import annotations import random import asyncio import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.provider.message as provider_message from .. import stage, entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @stage.stage_class('SendResponseBackStage') class SendResponseBackStage(stage.PipelineStage): """发送响应消息""" async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: """处理""" random_range = ( query.pipeline_config['output']['force-delay']['min'], query.pipeline_config['output']['force-delay']['max'], ) random_delay = random.uniform(*random_range) self.ap.logger.debug('根据规则强制延迟回复: %s s', random_delay) await asyncio.sleep(random_delay) if query.pipeline_config['output']['misc']['at-sender'] and isinstance( query.message_event, platform_events.GroupMessage ): query.resp_message_chain[-1].insert(0, platform_message.At(target=query.message_event.sender.id)) quote_origin = query.pipeline_config['output']['misc']['quote-origin'] has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages) # TODO 命令与流式的兼容性问题 if await query.adapter.is_stream_output_supported() and has_chunks: is_final = [msg.is_final for msg in query.resp_messages][0] await query.adapter.reply_message_chunk( message_source=query.message_event, bot_message=query.resp_messages[-1], message=query.resp_message_chain[-1], quote_origin=quote_origin, is_final=is_final, ) else: await query.adapter.reply_message( message_source=query.message_event, message=query.resp_message_chain[-1], quote_origin=quote_origin, ) return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) ================================================ FILE: src/langbot/pkg/pipeline/resprule/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/resprule/entities.py ================================================ import pydantic import langbot_plugin.api.entities.builtin.platform.message as platform_message class RuleJudgeResult(pydantic.BaseModel): matching: bool = False replacement: platform_message.MessageChain = None ================================================ FILE: src/langbot/pkg/pipeline/resprule/resprule.py ================================================ from __future__ import annotations from . import rule from .. import stage, entities from ...utils import importutil import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query from . import rules importutil.import_modules_in_pkg(rules) @stage.stage_class('GroupRespondRuleCheckStage') class GroupRespondRuleCheckStage(stage.PipelineStage): """群组响应规则检查器 仅检查群消息是否符合规则。 """ rule_matchers: list[rule.GroupRespondRule] """检查器实例""" async def initialize(self, pipeline_config: dict): """初始化检查器""" self.rule_matchers = [] for rule_matcher in rule.preregisetered_rules: rule_inst = rule_matcher(self.ap) await rule_inst.initialize() self.rule_matchers.append(rule_inst) async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: if query.launcher_type.value != 'group': # 只处理群消息 return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) rules = query.pipeline_config['trigger']['group-respond-rules'] use_rule = rules # TODO revert it # if str(query.launcher_id) in rules: # use_rule = rules[str(query.launcher_id)] for rule_matcher in self.rule_matchers: # 任意一个匹配就放行 res = await rule_matcher.match(str(query.message_chain), query.message_chain, use_rule, query) if res.matching: query.message_chain = res.replacement return entities.StageProcessResult( result_type=entities.ResultType.CONTINUE, new_query=query, ) return entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) ================================================ FILE: src/langbot/pkg/pipeline/resprule/rule.py ================================================ from __future__ import annotations import abc import typing from ...core import app from . import entities import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query preregisetered_rules: list[typing.Type[GroupRespondRule]] = [] def rule_class(name: str): def decorator(cls: typing.Type[GroupRespondRule]) -> typing.Type[GroupRespondRule]: cls.name = name preregisetered_rules.append(cls) return cls return decorator class GroupRespondRule(metaclass=abc.ABCMeta): """群组响应规则的抽象类""" name: str ap: app.Application def __init__(self, ap: app.Application): self.ap = ap async def initialize(self): pass @abc.abstractmethod async def match( self, message_text: str, message_chain: platform_message.MessageChain, rule_dict: dict, query: pipeline_query.Query, ) -> entities.RuleJudgeResult: """判断消息是否匹配规则""" raise NotImplementedError ================================================ FILE: src/langbot/pkg/pipeline/resprule/rules/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/resprule/rules/atbot.py ================================================ from __future__ import annotations from .. import rule as rule_model from .. import entities import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @rule_model.rule_class('at-bot') class AtBotRule(rule_model.GroupRespondRule): async def match( self, message_text: str, message_chain: platform_message.MessageChain, rule_dict: dict, query: pipeline_query.Query, ) -> entities.RuleJudgeResult: found = False def remove_at(message_chain: platform_message.MessageChain): nonlocal found for component in message_chain.root: if isinstance(component, platform_message.At) and str(component.target) == str( query.adapter.bot_account_id ): message_chain.remove(component) found = True break remove_at(message_chain) remove_at(message_chain) # 回复消息时会at两次,检查并删除重复的 should_respond_at = rule_dict.get('at', None) if should_respond_at is not None: return entities.RuleJudgeResult(matching=found and bool(should_respond_at), replacement=message_chain) return entities.RuleJudgeResult(matching=found, replacement=message_chain) ================================================ FILE: src/langbot/pkg/pipeline/resprule/rules/prefix.py ================================================ from .. import rule as rule_model from .. import entities import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @rule_model.rule_class('prefix') class PrefixRule(rule_model.GroupRespondRule): async def match( self, message_text: str, message_chain: platform_message.MessageChain, rule_dict: dict, query: pipeline_query.Query, ) -> entities.RuleJudgeResult: prefixes = rule_dict['prefix'] for prefix in prefixes: if message_text.startswith(prefix): # 查找第一个plain元素 for me in message_chain: if isinstance(me, platform_message.Plain): me.text = me.text[len(prefix) :] return entities.RuleJudgeResult( matching=True, replacement=message_chain, ) return entities.RuleJudgeResult(matching=False, replacement=message_chain) ================================================ FILE: src/langbot/pkg/pipeline/resprule/rules/random.py ================================================ import random from .. import rule as rule_model from .. import entities import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @rule_model.rule_class('random') class RandomRespRule(rule_model.GroupRespondRule): async def match( self, message_text: str, message_chain: platform_message.MessageChain, rule_dict: dict, query: pipeline_query.Query, ) -> entities.RuleJudgeResult: random_rate = rule_dict['random'] return entities.RuleJudgeResult(matching=random.random() < random_rate, replacement=message_chain) ================================================ FILE: src/langbot/pkg/pipeline/resprule/rules/regexp.py ================================================ import re from .. import rule as rule_model from .. import entities import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @rule_model.rule_class('regexp') class RegExpRule(rule_model.GroupRespondRule): async def match( self, message_text: str, message_chain: platform_message.MessageChain, rule_dict: dict, query: pipeline_query.Query, ) -> entities.RuleJudgeResult: regexps = rule_dict['regexp'] for regexp in regexps: match = re.match(regexp, message_text) if match: return entities.RuleJudgeResult( matching=True, replacement=message_chain, ) return entities.RuleJudgeResult(matching=False, replacement=message_chain) ================================================ FILE: src/langbot/pkg/pipeline/stage.py ================================================ from __future__ import annotations import abc import typing from ..core import app from . import entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query preregistered_stages: dict[str, type[PipelineStage]] = {} def stage_class(name: str) -> typing.Callable[[type[PipelineStage]], type[PipelineStage]]: def decorator(cls: type[PipelineStage]) -> type[PipelineStage]: preregistered_stages[name] = cls return cls return decorator class PipelineStage(metaclass=abc.ABCMeta): """流水线阶段""" ap: app.Application def __init__(self, ap: app.Application): self.ap = ap async def initialize(self, pipeline_config: dict): """初始化""" pass @abc.abstractmethod async def process( self, query: pipeline_query.Query, stage_inst_name: str, ) -> typing.Union[ entities.StageProcessResult, typing.AsyncGenerator[entities.StageProcessResult, None], ]: """处理""" raise NotImplementedError ================================================ FILE: src/langbot/pkg/pipeline/wrapper/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/pipeline/wrapper/wrapper.py ================================================ from __future__ import annotations import typing from .. import entities from .. import stage import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.events as events @stage.stage_class('ResponseWrapper') class ResponseWrapper(stage.PipelineStage): """回复包装阶段 把回复的 message 包装成人类识读的形式。 改写: - resp_message_chain """ async def initialize(self, pipeline_config: dict): pass async def process( self, query: pipeline_query.Query, stage_inst_name: str, ) -> typing.AsyncGenerator[entities.StageProcessResult, None]: """处理""" # 如果 resp_messages[-1] 已经是 MessageChain 了 if isinstance(query.resp_messages[-1], platform_message.MessageChain): query.resp_message_chain.append(query.resp_messages[-1]) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) else: if query.resp_messages[-1].role == 'command': query.resp_message_chain.append( query.resp_messages[-1].get_content_platform_message_chain(prefix_text='[bot] ') ) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) elif query.resp_messages[-1].role == 'plugin': query.resp_message_chain.append(query.resp_messages[-1].get_content_platform_message_chain()) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) else: if query.resp_messages[-1].role == 'assistant': result = query.resp_messages[-1] session = await self.ap.sess_mgr.get_session(query) reply_text = '' if result.content: # 有内容 reply_text = str(result.get_content_platform_message_chain()) # ============= 触发插件事件 =============== event = events.NormalMessageResponded( launcher_type=query.launcher_type.value, launcher_id=query.launcher_id, sender_id=query.sender_id, session=session, prefix='', response_text=reply_text, finish_reason='stop', funcs_called=[fc.function.name for fc in result.tool_calls] if result.tool_calls is not None else [], query=query, ) # Get bound plugins for filtering bound_plugins = query.variables.get('_pipeline_bound_plugins', None) event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) if event_ctx.is_prevented_default(): yield entities.StageProcessResult( result_type=entities.ResultType.INTERRUPT, new_query=query, ) else: if event_ctx.event.reply_message_chain is not None: query.resp_message_chain.append(event_ctx.event.reply_message_chain) else: query.resp_message_chain.append(result.get_content_platform_message_chain()) yield entities.StageProcessResult( result_type=entities.ResultType.CONTINUE, new_query=query, ) if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用 function_names = [tc.function.name for tc in result.tool_calls] reply_text = f'Call {".".join(function_names)}...' query.resp_message_chain.append( platform_message.MessageChain([platform_message.Plain(text=reply_text)]) ) if query.pipeline_config['output']['misc']['track-function-calls']: event = events.NormalMessageResponded( launcher_type=query.launcher_type.value, launcher_id=query.launcher_id, sender_id=query.sender_id, session=session, prefix='', response_text=reply_text, finish_reason='stop', funcs_called=[fc.function.name for fc in result.tool_calls] if result.tool_calls is not None else [], query=query, ) # Get bound plugins for filtering bound_plugins = query.variables.get('_pipeline_bound_plugins', None) event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) if event_ctx.is_prevented_default(): yield entities.StageProcessResult( result_type=entities.ResultType.INTERRUPT, new_query=query, ) else: if event_ctx.event.reply_message_chain is not None: query.resp_message_chain.append(event_ctx.event.reply_message_chain) else: query.resp_message_chain.append( platform_message.MessageChain([platform_message.Plain(text=reply_text)]) ) yield entities.StageProcessResult( result_type=entities.ResultType.CONTINUE, new_query=query, ) ================================================ FILE: src/langbot/pkg/platform/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/platform/botmgr.py ================================================ from __future__ import annotations import asyncio import traceback import sqlalchemy from ..core import app, entities as core_entities, taskmgr from ..discover import engine from ..entity.persistence import bot as persistence_bot from ..entity.errors import platform as platform_errors from .logger import EventLogger import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter class RuntimeBot: """运行时机器人""" ap: app.Application bot_entity: persistence_bot.Bot enable: bool adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter task_wrapper: taskmgr.TaskWrapper task_context: taskmgr.TaskContext logger: EventLogger def __init__( self, ap: app.Application, bot_entity: persistence_bot.Bot, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, logger: EventLogger, ): self.ap = ap self.bot_entity = bot_entity self.enable = bot_entity.enable self.adapter = adapter self.task_context = taskmgr.TaskContext() self.logger = logger async def initialize(self): async def on_friend_message( event: platform_events.FriendMessage, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, ): image_components = [ component for component in event.message_chain if isinstance(component, platform_message.Image) ] await self.logger.info( f'{event.message_chain}', images=image_components, message_session_id=f'person_{event.sender.id}', ) # Push to webhooks and check if pipeline should be skipped skip_pipeline = False if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: skip_pipeline = await self.ap.webhook_pusher.push_person_message( event, self.bot_entity.uuid, adapter.__class__.__name__ ) # Only add to query pool if no webhook requested to skip pipeline if not skip_pipeline: launcher_id = event.sender.id if hasattr(adapter, 'get_launcher_id'): custom_launcher_id = adapter.get_launcher_id(event) if custom_launcher_id: launcher_id = custom_launcher_id await self.ap.msg_aggregator.add_message( bot_uuid=self.bot_entity.uuid, launcher_type=provider_session.LauncherTypes.PERSON, launcher_id=launcher_id, sender_id=event.sender.id, message_event=event, message_chain=event.message_chain, adapter=adapter, pipeline_uuid=self.bot_entity.use_pipeline_uuid, ) else: await self.logger.info('Pipeline skipped for person message due to webhook response') async def on_group_message( event: platform_events.GroupMessage, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, ): image_components = [ component for component in event.message_chain if isinstance(component, platform_message.Image) ] await self.logger.info( f'{event.message_chain}', images=image_components, message_session_id=f'group_{event.group.id}', ) # Push to webhooks and check if pipeline should be skipped skip_pipeline = False if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: skip_pipeline = await self.ap.webhook_pusher.push_group_message( event, self.bot_entity.uuid, adapter.__class__.__name__ ) # Only add to query pool if no webhook requested to skip pipeline if not skip_pipeline: launcher_id = event.group.id if hasattr(adapter, 'get_launcher_id'): custom_launcher_id = adapter.get_launcher_id(event) if custom_launcher_id: launcher_id = custom_launcher_id await self.ap.msg_aggregator.add_message( bot_uuid=self.bot_entity.uuid, launcher_type=provider_session.LauncherTypes.GROUP, launcher_id=launcher_id, sender_id=event.sender.id, message_event=event, message_chain=event.message_chain, adapter=adapter, pipeline_uuid=self.bot_entity.use_pipeline_uuid, ) else: await self.logger.info('Pipeline skipped for group message due to webhook response') self.adapter.register_listener(platform_events.FriendMessage, on_friend_message) self.adapter.register_listener(platform_events.GroupMessage, on_group_message) async def run(self): async def exception_wrapper(): try: self.task_context.set_current_action('Running...') await self.adapter.run_async() self.task_context.set_current_action('Exited.') except Exception as e: if isinstance(e, asyncio.CancelledError): self.task_context.set_current_action('Exited.') return traceback_str = traceback.format_exc() self.task_context.set_current_action('Exited with error.') await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback_str}') self.task_wrapper = self.ap.task_mgr.create_task( exception_wrapper(), kind='platform-adapter', name=f'platform-adapter-{self.adapter.__class__.__name__}', context=self.task_context, scopes=[ core_entities.LifecycleControlScope.APPLICATION, core_entities.LifecycleControlScope.PLATFORM, ], ) async def shutdown(self): await self.adapter.kill() self.ap.task_mgr.cancel_task(self.task_wrapper.id) # 控制QQ消息输入输出的类 class PlatformManager: # ====== 4.0 ====== ap: app.Application = None bots: list[RuntimeBot] websocket_proxy_bot: RuntimeBot adapter_components: list[engine.Component] adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] def __init__(self, ap: app.Application = None): self.ap = ap self.bots = [] self.adapter_components = [] self.adapter_dict = {} async def initialize(self): # delete all bot log images await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images') self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter') adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {} for component in self.adapter_components: adapter_dict[component.metadata.name] = component.get_python_component_class() self.adapter_dict = adapter_dict # initialize websocket adapter websocket_adapter_class = self.adapter_dict['websocket'] websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap) websocket_adapter_inst = websocket_adapter_class( {}, websocket_logger, ap=self.ap, ) self.websocket_proxy_bot = RuntimeBot( ap=self.ap, bot_entity=persistence_bot.Bot( uuid='websocket-proxy-bot', name='WebSocket', description='', adapter='websocket', adapter_config={}, enable=True, ), adapter=websocket_adapter_inst, logger=websocket_logger, ) await self.websocket_proxy_bot.initialize() await self.load_bots_from_db() def get_running_adapters(self) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]: return [bot.adapter for bot in self.bots if bot.enable] async def load_bots_from_db(self): self.ap.logger.info('Loading bots from db...') self.bots = [] result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot)) bots = result.all() for bot in bots: # load all bots here, enable or disable will be handled in runtime try: await self.load_bot(bot) except platform_errors.AdapterNotFoundError as e: self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}') except Exception as e: self.ap.logger.error(f'Failed to load bot {bot.uuid}: {e}\n{traceback.format_exc()}') async def load_bot( self, bot_entity: persistence_bot.Bot | sqlalchemy.Row[persistence_bot.Bot] | dict, ) -> RuntimeBot: """加载机器人""" if isinstance(bot_entity, sqlalchemy.Row): bot_entity = persistence_bot.Bot(**bot_entity._mapping) elif isinstance(bot_entity, dict): bot_entity = persistence_bot.Bot(**bot_entity) logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap) if bot_entity.adapter not in self.adapter_dict: raise platform_errors.AdapterNotFoundError(bot_entity.adapter) adapter_inst = self.adapter_dict[bot_entity.adapter]( bot_entity.adapter_config, logger, ) # 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook) if hasattr(adapter_inst, 'set_bot_uuid'): adapter_inst.set_bot_uuid(bot_entity.uuid) runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger) await runtime_bot.initialize() self.bots.append(runtime_bot) return runtime_bot async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None: if self.websocket_proxy_bot and self.websocket_proxy_bot.bot_entity.uuid == bot_uuid: return self.websocket_proxy_bot for bot in self.bots: if bot.bot_entity.uuid == bot_uuid: return bot return None async def remove_bot(self, bot_uuid: str): for bot in self.bots: if bot.bot_entity.uuid == bot_uuid: if bot.enable: await bot.shutdown() self.bots.remove(bot) return def get_available_adapters_info(self) -> list[dict]: return [ component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'websocket' ] def get_available_adapter_info_by_name(self, name: str) -> dict | None: for component in self.adapter_components: if component.metadata.name == name: return component.to_plain_dict() return None def get_available_adapter_manifest_by_name(self, name: str) -> engine.Component | None: for component in self.adapter_components: if component.metadata.name == name: return component return None async def run(self): # This method will only be called when the application launching await self.websocket_proxy_bot.run() for bot in self.bots: if bot.enable: await bot.run() async def shutdown(self): for bot in self.bots: if bot.enable: await bot.shutdown() self.ap.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.PLATFORM) ================================================ FILE: src/langbot/pkg/platform/logger.py ================================================ from __future__ import annotations import typing import mimetypes import time import enum import pydantic import traceback import uuid from ..core import app import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_event_logger class EventLogLevel(enum.Enum): """日志级别""" DEBUG = 'debug' INFO = 'info' WARNING = 'warning' ERROR = 'error' class EventLog(pydantic.BaseModel): seq_id: int """日志序号""" timestamp: int """日志时间戳""" level: EventLogLevel """日志级别""" text: str """日志文本""" images: typing.Optional[list[str]] = None """日志图片 URL 列表,需要通过 /api/v1/image/{uuid} 获取图片""" message_session_id: typing.Optional[str] = None """消息会话ID,仅收发消息事件有值""" def to_json(self) -> dict: return { 'seq_id': self.seq_id, 'timestamp': self.timestamp, 'level': self.level.value, 'text': self.text, 'images': self.images, 'message_session_id': self.message_session_id, } MAX_LOG_COUNT = 200 DELETE_COUNT_PER_TIME = 50 class EventLogger(abstract_platform_event_logger.AbstractEventLogger): """used for logging bot events""" ap: app.Application seq_id_inc: int logs: list[EventLog] def __init__( self, name: str, ap: app.Application, ): self.name = name self.ap = ap self.logs = [] self.seq_id_inc = 0 async def get_logs(self, from_seq_id: int, max_count: int) -> typing.Tuple[list[EventLog], int]: """ 获取日志,从 from_seq_id 开始获取 max_count 条历史日志 Args: from_seq_id: 起始序号,-1 表示末尾 max_count: 最大数量 Returns: Tuple[list[EventLog], int]: 日志列表,日志总数 """ if len(self.logs) == 0: return [], 0 if from_seq_id <= -1: from_seq_id = self.logs[-1].seq_id min_seq_id_in_logs = self.logs[0].seq_id max_seq_id_in_logs = self.logs[-1].seq_id if from_seq_id < min_seq_id_in_logs: # 需要的整个范围都已经被删除 return [], len(self.logs) if ( from_seq_id > max_seq_id_in_logs and from_seq_id - max_count > max_seq_id_in_logs ): # 需要的整个范围都还没生成 return [], len(self.logs) end_index = 1 for i, log in enumerate(self.logs): if log.seq_id >= from_seq_id: end_index = i + 1 break start_index = max(0, end_index - max_count) if max_count > 0: return self.logs[start_index:end_index], len(self.logs) else: return [], len(self.logs) async def _truncate_logs(self): if len(self.logs) > MAX_LOG_COUNT: for i in range(DELETE_COUNT_PER_TIME): for image_key in self.logs[i].images: # type: ignore await self.ap.storage_mgr.storage_provider.delete(image_key) self.logs = self.logs[DELETE_COUNT_PER_TIME:] async def _add_log( self, level: EventLogLevel, text: str, images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, ): try: image_keys = [] if images is None: images = [] if message_session_id is None: message_session_id = '' if not isinstance(message_session_id, str): message_session_id = str(message_session_id) for img in images: img_bytes, mime_type = await img.get_bytes() extension = mimetypes.guess_extension(mime_type) if extension is None: extension = '.jpg' image_key = f'bot_log_images/{message_session_id}-{uuid.uuid4()}{extension}' await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes) image_keys.append(image_key) self.logs.append( EventLog( seq_id=self.seq_id_inc, timestamp=int(time.time()), level=level, text=text, images=image_keys, message_session_id=message_session_id, ) ) self.seq_id_inc += 1 await self._truncate_logs() except Exception as e: if not no_throw: raise e else: traceback.print_exc() async def info( self, text: str, images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, ): await self._add_log( level=EventLogLevel.INFO, text=text, images=images, message_session_id=message_session_id, no_throw=no_throw, ) async def debug( self, text: str, images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, ): await self._add_log( level=EventLogLevel.DEBUG, text=text, images=images, message_session_id=message_session_id, no_throw=no_throw, ) async def warning( self, text: str, images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, ): await self._add_log( level=EventLogLevel.WARNING, text=text, images=images, message_session_id=message_session_id, no_throw=no_throw, ) async def error( self, text: str, images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, ): await self._add_log( level=EventLogLevel.ERROR, text=text, images=images, message_session_id=message_session_id, no_throw=no_throw, ) ================================================ FILE: src/langbot/pkg/platform/sources/__init__.py ================================================ ================================================ FILE: src/langbot/pkg/platform/sources/aiocqhttp.py ================================================ from __future__ import annotations import typing import asyncio import traceback import datetime import aiocqhttp import pydantic import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities from ...utils import image import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target( message_chain: platform_message.MessageChain, ) -> typing.Tuple[list, int, datetime.datetime]: msg_list = aiocqhttp.Message() msg_id = 0 msg_time = None for msg in message_chain: if type(msg) is platform_message.Plain: msg_list.append(aiocqhttp.MessageSegment.text(msg.text)) elif type(msg) is platform_message.Source: msg_id = msg.id msg_time = msg.time elif type(msg) is platform_message.Image: arg = '' if msg.base64: arg = msg.base64 msg_list.append(aiocqhttp.MessageSegment.image(f'base64://{arg}')) elif msg.url: arg = msg.url msg_list.append(aiocqhttp.MessageSegment.image(arg)) elif msg.path: arg = msg.path msg_list.append(aiocqhttp.MessageSegment.image(arg)) elif type(msg) is platform_message.At: msg_list.append(aiocqhttp.MessageSegment.at(msg.target)) elif type(msg) is platform_message.AtAll: msg_list.append(aiocqhttp.MessageSegment.at('all')) elif type(msg) is platform_message.Voice: arg = '' if msg.base64: arg = msg.base64 msg_list.append(aiocqhttp.MessageSegment.record(f'base64://{arg}')) elif msg.url: arg = msg.url msg_list.append(aiocqhttp.MessageSegment.record(arg)) elif msg.path: arg = msg.path msg_list.append(aiocqhttp.MessageSegment.record(msg.path)) elif type(msg) is platform_message.Forward: for node in msg.node_list: msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0]) elif isinstance(msg, platform_message.File): msg_list.append({'type': 'file', 'data': {'file': msg.url, 'name': msg.name}}) elif isinstance(msg, platform_message.Face): if msg.face_type == 'face': msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id)) elif msg.face_type == 'rps': msg_list.append(aiocqhttp.MessageSegment.rps()) elif msg.face_type == 'dice': msg_list.append(aiocqhttp.MessageSegment.dice()) else: msg_list.append(aiocqhttp.MessageSegment.text(str(msg))) return msg_list, msg_id, msg_time @staticmethod async def target2yiri(message: str, message_id: int = -1, bot: aiocqhttp.CQHttp = None): message = aiocqhttp.Message(message) def get_face_name(face_id): face_code_dict = { '2': '好色', '4': '得意', '5': '流泪', '8': '睡', '9': '大哭', '10': '尴尬', '12': '调皮', '14': '微笑', '16': '酷', '21': '可爱', '23': '傲慢', '24': '饥饿', '25': '困', '26': '惊恐', '27': '流汗', '28': '憨笑', '29': '悠闲', '30': '奋斗', '32': '疑问', '33': '嘘', '34': '晕', '38': '敲打', '39': '再见', '41': '发抖', '42': '爱情', '43': '跳跳', '49': '拥抱', '53': '蛋糕', '60': '咖啡', '63': '玫瑰', '66': '爱心', '74': '太阳', '75': '月亮', '76': '赞', '78': '握手', '79': '胜利', '85': '飞吻', '89': '西瓜', '96': '冷汗', '97': '擦汗', '98': '抠鼻', '99': '鼓掌', '100': '糗大了', '101': '坏笑', '102': '左哼哼', '103': '右哼哼', '104': '哈欠', '106': '委屈', '109': '左亲亲', '111': '可怜', '116': '示爱', '118': '抱拳', '120': '拳头', '122': '爱你', '123': 'NO', '124': 'OK', '125': '转圈', '129': '挥手', '144': '喝彩', '147': '棒棒糖', '171': '茶', '173': '泪奔', '174': '无奈', '175': '卖萌', '176': '小纠结', '179': 'doge', '180': '惊喜', '181': '骚扰', '182': '笑哭', '183': '我最美', '201': '点赞', '203': '托脸', '212': '托腮', '214': '啵啵', '219': '蹭一蹭', '222': '抱抱', '227': '拍手', '232': '佛系', '240': '喷脸', '243': '甩头', '246': '加油抱抱', '262': '脑阔疼', '264': '捂脸', '265': '辣眼睛', '266': '哦哟', '267': '头秃', '268': '问号脸', '269': '暗中观察', '270': 'emm', '271': '吃瓜', '272': '呵呵哒', '273': '我酸了', '277': '汪汪', '278': '汗', '281': '无眼笑', '282': '敬礼', '284': '面无表情', '285': '摸鱼', '287': '哦', '289': '睁眼', '290': '敲开心', '293': '摸锦鲤', '294': '期待', '297': '拜谢', '298': '元宝', '299': '牛啊', '305': '右亲亲', '306': '牛气冲天', '307': '喵喵', '314': '仔细分析', '315': '加油', '318': '崇拜', '319': '比心', '320': '庆祝', '322': '拒绝', '324': '吃糖', '326': '生气', } return face_code_dict.get(face_id, '') async def process_message_data(msg_data, reply_list): if msg_data['type'] == 'image': image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url']) reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')) elif msg_data['type'] == 'text': reply_list.append(platform_message.Plain(text=msg_data['data']['text'])) elif msg_data['type'] == 'forward': # 这里来应该传入转发消息组,暂时传入Quote for forward_msg_datas in msg_data['data']['content']: for forward_msg_data in forward_msg_datas['message']: await process_message_data(forward_msg_data, reply_list) elif msg_data['type'] == 'at': if msg_data['data']['qq'] == 'all': reply_list.append(platform_message.AtAll()) else: reply_list.append( platform_message.At( target=msg_data['data']['qq'], ) ) yiri_msg_list = [] yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now())) for msg in message: reply_list = [] if msg.type == 'at': if msg.data['qq'] == 'all': yiri_msg_list.append(platform_message.AtAll()) else: yiri_msg_list.append( platform_message.At( target=msg.data['qq'], ) ) elif msg.type == 'text': yiri_msg_list.append(platform_message.Plain(text=msg.data['text'])) elif msg.type == 'image': emoji_id = msg.data.get('emoji_package_id', None) if emoji_id: face_id = emoji_id face_name = msg.data.get('summary', '') image_msg = platform_message.Face(face_id=face_id, face_name=face_name) else: image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url']) image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}') yiri_msg_list.append(image_msg) elif msg.type == 'forward': # 暂时不太合理 # msg_datas = await bot.get_msg(message_id=message_id) # print(msg_datas) # for msg_data in msg_datas["message"]: # await process_message_data(msg_data, yiri_msg_list) pass elif msg.type == 'reply': # 此处处理引用消息传入Quote msg_datas = await bot.get_msg(message_id=msg.data['id']) for msg_data in msg_datas['message']: await process_message_data(msg_data, reply_list) reply_msg = platform_message.Quote( message_id=msg.data['id'], sender_id=msg_datas['user_id'], origin=reply_list ) yiri_msg_list.append(reply_msg) elif msg.type == 'file': pass # file_name = msg.data['file'] # file_id = msg.data['file_id'] # file_data = await bot.get_file(file_id=file_id) # file_name = file_data.get('file_name') # file_path = file_data.get('file') # _ = file_path # file_url = file_data.get('file_url') # file_size = file_data.get('file_size') # yiri_msg_list.append(platform_message.File(id=file_id, name=file_name,url=file_url,size=file_size)) elif msg.type == 'face': face_id = msg.data['id'] face_name = msg.data['raw']['faceText'] if not face_name: face_name = get_face_name(face_id) yiri_msg_list.append(platform_message.Face(face_id=int(face_id), face_name=face_name.replace('/', ''))) elif msg.type == 'rps': face_id = msg.data['result'] yiri_msg_list.append(platform_message.Face(face_type='rps', face_id=int(face_id), face_name='猜拳')) elif msg.type == 'dice': face_id = msg.data['result'] yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子')) chain = platform_message.MessageChain(yiri_msg_list) return chain class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int): return event.source_platform_object @staticmethod async def target2yiri(event: aiocqhttp.Event, bot=None): yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot) if event.message_type == 'group': permission = 'MEMBER' if 'role' in event.sender: if event.sender['role'] == 'admin': permission = 'ADMINISTRATOR' elif event.sender['role'] == 'owner': permission = 'OWNER' converted_event = platform_events.GroupMessage( sender=platform_entities.GroupMember( id=event.sender['user_id'], # message_seq 放哪? member_name=event.sender['nickname'], permission=permission, group=platform_entities.Group( id=event.group_id, name=event.sender['nickname'], permission=platform_entities.Permission.Member, ), special_title=event.sender['title'] if 'title' in event.sender else '', ), message_chain=yiri_chain, time=event.time, source_platform_object=event, ) return converted_event elif event.message_type == 'private': return platform_events.FriendMessage( sender=platform_entities.Friend( id=event.sender['user_id'], nickname=event.sender['nickname'], remark='', ), message_chain=yiri_chain, time=event.time, source_platform_object=event, ) class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp) message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter() event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter() on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = [] def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): super().__init__( config=config, logger=logger, ) async def shutdown_trigger_placeholder(): while True: await asyncio.sleep(1) self.config['shutdown_trigger'] = shutdown_trigger_placeholder self.on_websocket_connection_event_cache = [] if 'access-token' in config: self.bot = aiocqhttp.CQHttp(access_token=config['access-token']) del self.config['access-token'] else: self.bot = aiocqhttp.CQHttp() async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): # Check if message contains a Forward component forward_msg = message.get_first(platform_message.Forward) if forward_msg: if target_type == 'group': # Send as merged forward message via OneBot API await self._send_forward_message(int(target_id), forward_msg) return else: await self.logger.warning( f'Forward message is only supported for group targets, got target_type={target_type}. Falling through to normal send.' ) aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0] if target_type == 'group': await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg) elif target_type == 'person': await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg) async def _send_forward_message(self, group_id: int, forward: platform_message.Forward): """Send a merged forward message to a group using NapCat extended API.""" messages = [] for node in forward.node_list: # Build content for each node content = [] if node.message_chain: for component in node.message_chain: if isinstance(component, platform_message.Plain): if component.text: content.append({'type': 'text', 'data': {'text': component.text}}) elif isinstance(component, platform_message.Image): img_data = {} if component.base64: b64 = component.base64 if b64.startswith('data:'): b64 = b64.split(',', 1)[-1] if ',' in b64 else b64 img_data['file'] = f'base64://{b64}' elif component.url: img_data['file'] = component.url elif component.path: img_data['file'] = str(component.path) if img_data: content.append({'type': 'image', 'data': img_data}) if not content: continue # Build node data - use user_id and nickname format for NapCat user_id = str(node.sender_id) if node.sender_id else str(self.bot_account_id or '10000') node_data = { 'type': 'node', 'data': { 'user_id': user_id, 'nickname': node.sender_name or '未知', 'content': content, }, } messages.append(node_data) if not messages: return # Build the full message payload for NapCat's send_forward_msg API # This matches the format used by GiveMeSetuPlugin bot_id = str(self.bot_account_id) if self.bot_account_id else '10000' payload = { 'group_id': group_id, 'user_id': bot_id, # Required by NapCat for display 'messages': messages, } # Add display settings if available if forward.display: if forward.display.title: payload['news'] = [{'text': forward.display.title}] if forward.display.brief: payload['prompt'] = forward.display.brief if forward.display.summary: payload['summary'] = forward.display.summary if forward.display.source: payload['source'] = forward.display.source try: # Use send_forward_msg (NapCat extended API) instead of send_group_forward_msg await self.logger.info( f'Sending forward message to group {group_id} with {len(messages)} nodes, payload keys: {list(payload.keys())}' ) result = await self.bot.call_action('send_forward_msg', **payload) await self.logger.info(f'Forward message sent to group {group_id}, result: {result}') except Exception as e: await self.logger.error(f'Failed to send forward message to group {group_id}: {e}') # Fallback: try standard OneBot API with integer group_id try: await self.logger.info('Trying fallback API send_group_forward_msg') await self.bot.call_action('send_group_forward_msg', group_id=group_id, messages=messages) await self.logger.info(f'Forward message sent via fallback API to group {group_id}') except Exception as e2: await self.logger.error(f'Fallback also failed: {e2}') raise async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): aiocq_event = await AiocqhttpEventConverter.yiri2target(message_source, self.bot_account_id) aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0] if quote_origin: aiocq_msg = aiocqhttp.MessageSegment.reply(aiocq_event.message_id) + aiocq_msg return await self.bot.send(aiocq_event, aiocq_msg) async def is_muted(self, group_id: int) -> bool: return False def register_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): async def on_message(event: aiocqhttp.Event): self.bot_account_id = event.self_id try: return await callback(await self.event_converter.target2yiri(event, self.bot), self) except Exception: await self.logger.error(f'Error in on_message: {traceback.format_exc()}') traceback.print_exc() if event_type == platform_events.GroupMessage: self.bot.on_message('group')(on_message) # self.bot.on_notice()(on_message) elif event_type == platform_events.FriendMessage: self.bot.on_message('private')(on_message) # self.bot.on_notice()(on_message) # print(event_type) async def on_websocket_connection(event: aiocqhttp.Event): for event in self.on_websocket_connection_event_cache: if event.self_id == event.self_id and event.time == event.time: return self.on_websocket_connection_event_cache.append(event) await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}') self.bot.on_websocket_connection(on_websocket_connection) def unregister_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): return super().unregister_listener(event_type, callback) async def run_async(self): await self.bot._server_app.run_task(**self.config) async def kill(self) -> bool: # Current issue: existing connection will not be closed # self.should_shutdown = True return False ================================================ FILE: src/langbot/pkg/platform/sources/aiocqhttp.yaml ================================================ apiVersion: v1 kind: MessagePlatformAdapter metadata: name: aiocqhttp label: en_US: OneBot v11 zh_Hans: OneBot v11 description: en_US: OneBot v11 Adapter zh_Hans: OneBot v11 适配器,请查看文档了解使用方式 icon: onebot.png spec: config: - name: host label: en_US: Host zh_Hans: 主机 description: en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0 zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0 type: string required: true default: 0.0.0.0 - name: port label: en_US: Port zh_Hans: 端口 description: en_US: Port zh_Hans: 监听的端口 type: integer required: true default: 2280 - name: access-token label: en_US: Access Token zh_Hans: 访问令牌 description: en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填 type: string required: false default: "" execution: python: path: ./aiocqhttp.py attr: AiocqhttpAdapter ================================================ FILE: src/langbot/pkg/platform/sources/dingtalk.py ================================================ import traceback import typing from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities from langbot.libs.dingtalk_api.api import DingTalkClient import datetime from langbot.pkg.platform.logger import EventLogger class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod def _format_image_as_markdown(msg: platform_message.Image) -> str: """Convert an Image message to Markdown format for DingTalk.""" if msg.url: return f'\n![image]({msg.url})\n' elif msg.base64: # For base64 images, try to include them as data URIs # DingTalk may have limited support for base64 in markdown if msg.base64.startswith('data:'): return f'\n![image]({msg.base64})\n' else: return f'\n![image](data:image/png;base64,{msg.base64})\n' return '' @staticmethod async def yiri2target(message_chain: platform_message.MessageChain, markdown_enabled: bool = True): content = '' at = False for msg in message_chain: if type(msg) is platform_message.At: at = True elif type(msg) is platform_message.Plain: content += msg.text elif type(msg) is platform_message.Image: # DingTalk supports markdown images when markdown_card is enabled # When markdown is disabled, images cannot be rendered in plain text mode if markdown_enabled: content += DingTalkMessageConverter._format_image_as_markdown(msg) # Note: When markdown_enabled is False, images are not included # as DingTalk plain text messages don't support image embedding elif type(msg) is platform_message.Forward: for node in msg.node_list: forwarded_content, _ = await DingTalkMessageConverter.yiri2target( node.message_chain, markdown_enabled ) content += forwarded_content return content, at @staticmethod async def target2yiri(event: DingTalkEvent, bot_name: str): yiri_msg_list = [] yiri_msg_list.append( platform_message.Source(id=event.incoming_message.message_id, time=datetime.datetime.now()) ) for atUser in event.incoming_message.at_users: if atUser.dingtalk_id == event.incoming_message.chatbot_user_id: yiri_msg_list.append(platform_message.At(target=bot_name)) if event.rich_content: elements = event.rich_content.get('Elements') for element in elements: if element.get('Type') == 'text': text = element.get('Content', '').replace('@' + bot_name, '') if text.strip(): yiri_msg_list.append(platform_message.Plain(text=text)) elif element.get('Type') == 'image' and element.get('Picture'): yiri_msg_list.append(platform_message.Image(base64=element['Picture'])) else: # 回退到原有简单逻辑 if event.content: text_content = event.content.replace('@' + bot_name, '') yiri_msg_list.append(platform_message.Plain(text=text_content)) if event.picture: yiri_msg_list.append(platform_message.Image(base64=event.picture)) # 处理其他类型消息(文件、音频等) if event.file: yiri_msg_list.append(platform_message.File(url=event.file, name=event.name)) if event.audio: yiri_msg_list.append(platform_message.Voice(base64=event.audio)) chain = platform_message.MessageChain(yiri_msg_list) return chain class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def yiri2target(event: platform_events.MessageEvent): return event.source_platform_object @staticmethod async def target2yiri(event: DingTalkEvent, bot_name: str): message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name) if event.conversation == 'FriendMessage': return platform_events.FriendMessage( sender=platform_entities.Friend( id=event.incoming_message.sender_staff_id, nickname=event.incoming_message.sender_nick, remark='', ), message_chain=message_chain, time=event.incoming_message.create_at, source_platform_object=event, ) elif event.conversation == 'GroupMessage': sender = platform_entities.GroupMember( id=event.incoming_message.sender_staff_id, member_name=event.incoming_message.sender_nick, permission='MEMBER', group=platform_entities.Group( id=event.incoming_message.conversation_id, name=event.incoming_message.conversation_title, permission=platform_entities.Permission.Member, ), special_title='', ) time = event.incoming_message.create_at return platform_events.GroupMessage( sender=sender, message_chain=message_chain, time=time, source_platform_object=event, ) class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: DingTalkClient bot_account_id: str message_converter: DingTalkMessageConverter = DingTalkMessageConverter() event_converter: DingTalkEventConverter = DingTalkEventConverter() config: dict card_instance_id_dict: ( dict # 回复卡片消息字典,key为消息id,value为回复卡片实例id,用于在流式消息时判断是否发送到指定卡片 ) def __init__(self, config: dict, logger: EventLogger): required_keys = [ 'client_id', 'client_secret', 'robot_name', 'robot_code', ] missing_keys = [key for key in required_keys if key not in config] if missing_keys: raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员') bot = DingTalkClient( client_id=config['client_id'], client_secret=config['client_secret'], robot_name=config['robot_name'], robot_code=config['robot_code'], markdown_card=config['markdown_card'], logger=logger, ) bot_account_id = config['robot_name'] super().__init__( config=config, logger=logger, card_instance_id_dict={}, bot_account_id=bot_account_id, bot=bot, listeners={}, ) async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): event = await DingTalkEventConverter.yiri2target( message_source, ) incoming_message = event.incoming_message markdown_enabled = self.config.get('markdown_card', False) content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) await self.bot.send_message(content, incoming_message, at) async def reply_message_chunk( self, message_source: platform_events.MessageEvent, bot_message, message: platform_message.MessageChain, quote_origin: bool = False, is_final: bool = False, ): # event = await DingTalkEventConverter.yiri2target( # message_source, # ) # incoming_message = event.incoming_message # msg_id = incoming_message.message_id message_id = bot_message.resp_message_id msg_seq = bot_message.msg_sequence if (msg_seq - 1) % 8 == 0 or is_final: markdown_enabled = self.config.get('markdown_card', False) content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) card_instance, card_instance_id = self.card_instance_id_dict[message_id] if not content and bot_message.content: content = bot_message.content # 兼容直接传入content的情况 # print(card_instance_id) if content: await self.bot.send_card_message(card_instance, card_instance_id, content, is_final) if is_final and bot_message.tool_calls is None: # self.seq = 1 # 消息回复结束之后重置seq self.card_instance_id_dict.pop(message_id) # 消息回复结束之后删除卡片实例id async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): markdown_enabled = self.config.get('markdown_card', False) content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) if target_type == 'person': await self.bot.send_proactive_message_to_one(target_id, content) if target_type == 'group': await self.bot.send_proactive_message_to_group(target_id, content) async def is_stream_output_supported(self) -> bool: is_stream = False if self.config.get('enable-stream-reply', None): is_stream = True return is_stream async def create_message_card(self, message_id, event): card_template_id = self.config['card_template_id'] incoming_message = event.source_platform_object.incoming_message # message_id = incoming_message.message_id card_auto_layout = self.config.get('card_ auto_layout', False) card_instance, card_instance_id = await self.bot.create_and_card( card_template_id, incoming_message, card_auto_layout=card_auto_layout ) self.card_instance_id_dict[message_id] = (card_instance, card_instance_id) return True def register_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): async def on_message(event: DingTalkEvent): try: return await callback( await self.event_converter.target2yiri(event, self.config['robot_name']), self, ) except Exception: await self.logger.error(f'Error in dingtalk callback: {traceback.format_exc()}') if event_type == platform_events.FriendMessage: self.bot.on_message('FriendMessage')(on_message) elif event_type == platform_events.GroupMessage: self.bot.on_message('GroupMessage')(on_message) async def run_async(self): await self.bot.start() async def kill(self) -> bool: await self.bot.stop() return True async def is_muted(self) -> bool: return False async def unregister_listener( self, event_type: type, callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): return super().unregister_listener(event_type, callback) ================================================ FILE: src/langbot/pkg/platform/sources/dingtalk.yaml ================================================ apiVersion: v1 kind: MessagePlatformAdapter metadata: name: dingtalk label: en_US: DingTalk zh_Hans: 钉钉 description: en_US: DingTalk Adapter zh_Hans: 钉钉适配器,请查看文档了解使用方式 icon: dingtalk.svg spec: config: - name: client_id label: en_US: Client ID zh_Hans: 客户端ID type: string required: true default: "" - name: client_secret label: en_US: Client Secret zh_Hans: 客户端密钥 type: string required: true default: "" - name: robot_code label: en_US: Robot Code zh_Hans: 机器人代码 type: string required: true default: "" - name: robot_name label: en_US: Robot Name zh_Hans: 机器人名称 type: string required: true default: "" - name: markdown_card label: en_US: Markdown Card zh_Hans: 是否使用 Markdown 卡片 type: boolean required: false default: true - name: enable-stream-reply label: en_US: Enable Stream Reply Mode zh_Hans: 启用钉钉卡片流式回复模式 description: en_US: If enabled, the bot will use the stream of lark reply mode zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容 type: boolean required: true default: false - name: card_auto_layout label: en_US: Card Auto Layout zh_Hans: 卡片宽屏自动布局 type: boolean required: false default: false - name: card_template_id label: en_US: card template id zh_Hans: 卡片模板ID type: string required: true default: "填写你的卡片template_id" execution: python: path: ./dingtalk.py attr: DingTalkAdapter ================================================ FILE: src/langbot/pkg/platform/sources/discord.py ================================================ from __future__ import annotations import discord import typing import re import base64 import uuid import os import datetime # 使用BytesIO创建文件对象,避免路径问题 import io import asyncio from enum import Enum from langbot.pkg.utils import httpclient import pydantic import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger from ..logger import EventLogger # 语音功能相关异常定义 class VoiceConnectionError(Exception): """语音连接基础异常""" def __init__(self, message: str, error_code: str = None, guild_id: int = None): super().__init__(message) self.error_code = error_code self.guild_id = guild_id self.timestamp = datetime.datetime.now() class VoicePermissionError(VoiceConnectionError): """语音权限异常""" def __init__(self, message: str, missing_permissions: list = None, user_id: int = None, channel_id: int = None): super().__init__(message, 'PERMISSION_ERROR') self.missing_permissions = missing_permissions or [] self.user_id = user_id self.channel_id = channel_id class VoiceNetworkError(VoiceConnectionError): """语音网络异常""" def __init__(self, message: str, retry_count: int = 0): super().__init__(message, 'NETWORK_ERROR') self.retry_count = retry_count self.last_attempt = datetime.datetime.now() class VoiceConnectionStatus(Enum): """语音连接状态枚举""" IDLE = 'idle' CONNECTING = 'connecting' CONNECTED = 'connected' PLAYING = 'playing' RECONNECTING = 'reconnecting' FAILED = 'failed' class VoiceConnectionInfo: """ 语音连接信息类 用于存储和管理单个语音连接的详细信息,包括连接状态、时间戳、 频道信息等。提供连接信息的标准化数据结构。 @author: @ydzat @version: 1.0 @since: 2025-07-04 """ def __init__(self, guild_id: int, channel_id: int, channel_name: str = None): """ 初始化语音连接信息 @author: @ydzat Args: guild_id (int): 服务器ID channel_id (int): 语音频道ID channel_name (str, optional): 语音频道名称 """ self.guild_id = guild_id self.channel_id = channel_id self.channel_name = channel_name or f'Channel-{channel_id}' self.connected = False self.connection_time: datetime.datetime = None self.last_activity = datetime.datetime.now() self.status = VoiceConnectionStatus.IDLE self.user_count = 0 self.latency = 0.0 self.connection_health = 'unknown' self.voice_client = None def update_status(self, status: VoiceConnectionStatus): """ 更新连接状态 @author: @ydzat Args: status (VoiceConnectionStatus): 新的连接状态 """ self.status = status self.last_activity = datetime.datetime.now() if status == VoiceConnectionStatus.CONNECTED: self.connected = True if self.connection_time is None: self.connection_time = datetime.datetime.now() elif status in [VoiceConnectionStatus.IDLE, VoiceConnectionStatus.FAILED]: self.connected = False self.connection_time = None self.voice_client = None def to_dict(self) -> dict: """ 转换为字典格式 @author: @ydzat Returns: dict: 连接信息的字典表示 """ return { 'guild_id': self.guild_id, 'channel_id': self.channel_id, 'channel_name': self.channel_name, 'connected': self.connected, 'connection_time': self.connection_time.isoformat() if self.connection_time else None, 'last_activity': self.last_activity.isoformat(), 'status': self.status.value, 'user_count': self.user_count, 'latency': self.latency, 'connection_health': self.connection_health, } class VoiceConnectionManager: """ 语音连接管理器 负责管理多个服务器的语音连接,提供连接建立、断开、状态查询等功能。 采用单例模式确保全局只有一个连接管理器实例。 @author: @ydzat @version: 1.0 @since: 2025-07-04 """ def __init__(self, bot: discord.Client, logger: EventLogger): """ 初始化语音连接管理器 @author: @ydzat Args: bot (discord.Client): Discord 客户端实例 logger (EventLogger): 事件日志记录器 """ self.bot = bot self.logger = logger self.connections: typing.Dict[int, VoiceConnectionInfo] = {} self._connection_lock = asyncio.Lock() self._cleanup_task = None self._monitoring_enabled = True async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient: """ 加入语音频道 验证用户权限和频道状态后,建立到指定语音频道的连接。 支持连接复用和自动重连机制。 @author: @ydzat Args: guild_id (int): 服务器ID channel_id (int): 语音频道ID user_id (int, optional): 请求用户ID,用于权限验证 Returns: discord.VoiceClient: 语音客户端实例 Raises: VoicePermissionError: 权限不足时抛出 VoiceNetworkError: 网络连接失败时抛出 VoiceConnectionError: 其他连接错误时抛出 """ async with self._connection_lock: try: # 获取服务器和频道对象 guild = self.bot.get_guild(guild_id) if not guild: raise VoiceConnectionError(f'无法找到服务器 {guild_id}', 'GUILD_NOT_FOUND', guild_id) channel = guild.get_channel(channel_id) if not channel or not isinstance(channel, discord.VoiceChannel): raise VoiceConnectionError(f'无法找到语音频道 {channel_id}', 'CHANNEL_NOT_FOUND', guild_id) # 验证用户是否在语音频道中(如果提供了用户ID) if user_id: await self._validate_user_in_channel(guild, channel, user_id) # 验证机器人权限 await self._validate_bot_permissions(channel) # 检查是否已有连接 if guild_id in self.connections: existing_conn = self.connections[guild_id] if existing_conn.connected and existing_conn.voice_client: if existing_conn.channel_id == channel_id: # 已连接到相同频道,返回现有连接 await self.logger.info(f'复用现有语音连接: {guild.name} -> {channel.name}') return existing_conn.voice_client else: # 连接到不同频道,先断开旧连接 await self._disconnect_internal(guild_id) # 建立新连接 voice_client = await channel.connect() # 更新连接信息 conn_info = VoiceConnectionInfo(guild_id, channel_id, channel.name) conn_info.voice_client = voice_client conn_info.update_status(VoiceConnectionStatus.CONNECTED) conn_info.user_count = len(channel.members) self.connections[guild_id] = conn_info await self.logger.info(f'成功连接到语音频道: {guild.name} -> {channel.name}') return voice_client except discord.ClientException as e: raise VoiceNetworkError(f'Discord 客户端错误: {str(e)}') except discord.opus.OpusNotLoaded as e: raise VoiceConnectionError(f'Opus 编码器未加载: {str(e)}', 'OPUS_NOT_LOADED', guild_id) except Exception as e: await self.logger.error(f'连接语音频道时发生未知错误: {str(e)}') raise VoiceConnectionError(f'连接失败: {str(e)}', 'UNKNOWN_ERROR', guild_id) async def leave_voice_channel(self, guild_id: int) -> bool: """ 离开语音频道 断开指定服务器的语音连接,清理相关资源和状态信息。 确保音频播放停止后再断开连接。 @author: @ydzat Args: guild_id (int): 服务器ID Returns: bool: 断开是否成功 """ async with self._connection_lock: return await self._disconnect_internal(guild_id) async def _disconnect_internal(self, guild_id: int) -> bool: """ 内部断开连接方法 @author: @ydzat Args: guild_id (int): 服务器ID Returns: bool: 断开是否成功 """ if guild_id not in self.connections: return True conn_info = self.connections[guild_id] try: if conn_info.voice_client and conn_info.voice_client.is_connected(): # 停止当前播放 if conn_info.voice_client.is_playing(): conn_info.voice_client.stop() # 等待播放完全停止 await asyncio.sleep(0.1) # 断开连接 await conn_info.voice_client.disconnect() conn_info.update_status(VoiceConnectionStatus.IDLE) del self.connections[guild_id] await self.logger.info(f'已断开语音连接: Guild {guild_id}') return True except Exception as e: await self.logger.error(f'断开语音连接时发生错误: {str(e)}') # 即使出错也要清理连接记录 conn_info.update_status(VoiceConnectionStatus.FAILED) if guild_id in self.connections: del self.connections[guild_id] return False async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]: """ 获取语音客户端 返回指定服务器的语音客户端实例,如果未连接则返回 None。 会验证连接的有效性,自动清理无效连接。 @author: @ydzat Args: guild_id (int): 服务器ID Returns: Optional[discord.VoiceClient]: 语音客户端实例或 None """ if guild_id not in self.connections: return None conn_info = self.connections[guild_id] # 验证连接是否仍然有效 if conn_info.voice_client and not conn_info.voice_client.is_connected(): # 连接已失效,清理状态 await self._disconnect_internal(guild_id) return None return conn_info.voice_client if conn_info.connected else None async def is_connected_to_voice(self, guild_id: int) -> bool: """ 检查是否连接到语音频道 @author: @ydzat Args: guild_id (int): 服务器ID Returns: bool: 是否已连接 """ if guild_id not in self.connections: return False conn_info = self.connections[guild_id] # 检查实际连接状态 if conn_info.voice_client and not conn_info.voice_client.is_connected(): # 连接已失效,清理状态 await self._disconnect_internal(guild_id) return False return conn_info.connected async def get_connection_status(self, guild_id: int) -> typing.Optional[dict]: """ 获取连接状态信息 @author: @ydzat Args: guild_id (int): 服务器ID Returns: Optional[dict]: 连接状态信息字典或 None """ if guild_id not in self.connections: return None conn_info = self.connections[guild_id] # 更新实时信息 if conn_info.voice_client and conn_info.voice_client.is_connected(): conn_info.latency = conn_info.voice_client.latency * 1000 # 转换为毫秒 conn_info.connection_health = 'good' if conn_info.latency < 100 else 'poor' # 更新频道用户数 guild = self.bot.get_guild(guild_id) if guild: channel = guild.get_channel(conn_info.channel_id) if channel and isinstance(channel, discord.VoiceChannel): conn_info.user_count = len(channel.members) return conn_info.to_dict() async def list_active_connections(self) -> typing.List[dict]: """ 列出所有活跃连接 @author: @ydzat Returns: List[dict]: 活跃连接列表 """ active_connections = [] for guild_id, conn_info in self.connections.items(): if conn_info.connected: status = await self.get_connection_status(guild_id) if status: active_connections.append(status) return active_connections async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]: """ 获取语音频道信息 @author: @ydzat Args: guild_id (int): 服务器ID channel_id (int): 频道ID Returns: Optional[dict]: 频道信息字典或 None """ guild = self.bot.get_guild(guild_id) if not guild: return None channel = guild.get_channel(channel_id) if not channel or not isinstance(channel, discord.VoiceChannel): return None # 获取用户信息 users = [] for member in channel.members: users.append( {'id': member.id, 'name': member.display_name, 'status': str(member.status), 'is_bot': member.bot} ) # 获取权限信息 bot_member = guild.me permissions = channel.permissions_for(bot_member) return { 'channel_id': channel_id, 'channel_name': channel.name, 'guild_id': guild_id, 'guild_name': guild.name, 'user_limit': channel.user_limit, 'current_users': users, 'user_count': len(users), 'bitrate': channel.bitrate, 'permissions': { 'connect': permissions.connect, 'speak': permissions.speak, 'use_voice_activation': permissions.use_voice_activation, 'priority_speaker': permissions.priority_speaker, }, } async def _validate_user_in_channel(self, guild: discord.Guild, channel: discord.VoiceChannel, user_id: int): """ 验证用户是否在语音频道中 @author: @ydzat Args: guild: Discord 服务器对象 channel: 语音频道对象 user_id: 用户ID Raises: VoicePermissionError: 用户不在频道中时抛出 """ member = guild.get_member(user_id) if not member: raise VoicePermissionError(f'无法找到用户 {user_id}', ['member_not_found'], user_id, channel.id) if not member.voice or member.voice.channel != channel: raise VoicePermissionError( f'用户 {member.display_name} 不在语音频道 {channel.name} 中', ['user_not_in_channel'], user_id, channel.id, ) async def _validate_bot_permissions(self, channel: discord.VoiceChannel): """ 验证机器人权限 @author: @ydzat Args: channel: 语音频道对象 Raises: VoicePermissionError: 权限不足时抛出 """ bot_member = channel.guild.me permissions = channel.permissions_for(bot_member) missing_permissions = [] if not permissions.connect: missing_permissions.append('connect') if not permissions.speak: missing_permissions.append('speak') if missing_permissions: raise VoicePermissionError( f'机器人在频道 {channel.name} 中缺少权限: {", ".join(missing_permissions)}', missing_permissions, channel_id=channel.id, ) async def cleanup_inactive_connections(self): """ 清理无效连接 定期检查并清理已断开或无效的语音连接,释放资源。 @author: @ydzat """ cleanup_guilds = [] for guild_id, conn_info in self.connections.items(): if not conn_info.voice_client or not conn_info.voice_client.is_connected(): cleanup_guilds.append(guild_id) for guild_id in cleanup_guilds: await self._disconnect_internal(guild_id) if cleanup_guilds: await self.logger.info(f'清理了 {len(cleanup_guilds)} 个无效的语音连接') async def start_monitoring(self): """ 开始连接监控 @author: @ydzat """ if self._cleanup_task is None and self._monitoring_enabled: self._cleanup_task = asyncio.create_task(self._monitoring_loop()) async def stop_monitoring(self): """ 停止连接监控 @author: @ydzat """ self._monitoring_enabled = False if self._cleanup_task: self._cleanup_task.cancel() try: await self._cleanup_task except asyncio.CancelledError: pass self._cleanup_task = None async def _monitoring_loop(self): """ 监控循环 @author: @ydzat """ try: while self._monitoring_enabled: await asyncio.sleep(60) # 每分钟检查一次 await self.cleanup_inactive_connections() except asyncio.CancelledError: pass async def disconnect_all(self): """ 断开所有连接 @author: @ydzat """ async with self._connection_lock: guild_ids = list(self.connections.keys()) for guild_id in guild_ids: await self._disconnect_internal(guild_id) await self.stop_monitoring() class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target( message_chain: platform_message.MessageChain, ) -> typing.Tuple[str, typing.List[discord.File]]: for ele in message_chain: if isinstance(ele, platform_message.At): message_chain.remove(ele) break text_string = '' files = [] for ele in message_chain: if isinstance(ele, platform_message.Image): image_bytes = None filename = f'{uuid.uuid4()}.png' # 默认文件名 if ele.base64: # 处理base64编码的图片 if ele.base64.startswith('data:'): # 从data URL中提取文件类型 data_header = ele.base64.split(',')[0] if 'jpeg' in data_header or 'jpg' in data_header: filename = f'{uuid.uuid4()}.jpg' elif 'gif' in data_header: filename = f'{uuid.uuid4()}.gif' elif 'webp' in data_header: filename = f'{uuid.uuid4()}.webp' # 去掉data:image/xxx;base64,前缀 base64_data = ele.base64.split(',')[1] else: base64_data = ele.base64 image_bytes = base64.b64decode(base64_data) elif ele.url: # 从URL下载图片 session = httpclient.get_session() async with session.get(ele.url) as response: image_bytes = await response.read() # 从URL或Content-Type推断文件类型 content_type = response.headers.get('Content-Type', '') if 'jpeg' in content_type or 'jpg' in content_type: filename = f'{uuid.uuid4()}.jpg' elif 'gif' in content_type: filename = f'{uuid.uuid4()}.gif' elif 'webp' in content_type: filename = f'{uuid.uuid4()}.webp' elif ele.url.lower().endswith(('.jpg', '.jpeg')): filename = f'{uuid.uuid4()}.jpg' elif ele.url.lower().endswith('.gif'): filename = f'{uuid.uuid4()}.gif' elif ele.url.lower().endswith('.webp'): filename = f'{uuid.uuid4()}.webp' elif ele.path: # 从文件路径读取图片 # 确保路径没有空字节 clean_path = ele.path.replace('\x00', '') clean_path = os.path.abspath(clean_path) if not os.path.exists(clean_path): continue # 跳过不存在的文件 try: with open(clean_path, 'rb') as f: image_bytes = f.read() # 从文件路径获取文件名,保持原始扩展名 original_filename = os.path.basename(clean_path) if original_filename and '.' in original_filename: # 保持原始文件名的扩展名 ext = original_filename.split('.')[-1].lower() filename = f'{uuid.uuid4()}.{ext}' else: # 如果没有扩展名,尝试从文件内容检测 if image_bytes.startswith(b'\xff\xd8\xff'): filename = f'{uuid.uuid4()}.jpg' elif image_bytes.startswith(b'GIF'): filename = f'{uuid.uuid4()}.gif' elif image_bytes.startswith(b'RIFF') and b'WEBP' in image_bytes[:20]: filename = f'{uuid.uuid4()}.webp' # 默认保持PNG except Exception as e: print(f'Error reading image file {clean_path}: {e}') continue # 跳过读取失败的文件 if image_bytes: files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename)) elif isinstance(ele, platform_message.Plain): text_string += ele.text elif isinstance(ele, platform_message.Voice): file_bytes = None filename = f'{uuid.uuid4()}.mp3' if ele.base64: if ele.base64.startswith('data:'): data_header = ele.base64.split(',')[0] if 'wav' in data_header: filename = f'{uuid.uuid4()}.wav' elif 'mp3' in data_header: filename = f'{uuid.uuid4()}.mp3' elif 'ogg' in data_header: filename = f'{uuid.uuid4()}.ogg' elif 'm4a' in data_header: filename = f'{uuid.uuid4()}.m4a' elif 'aac' in data_header: filename = f'{uuid.uuid4()}.aac' elif 'flac' in data_header: filename = f'{uuid.uuid4()}.flac' elif 'alac' in data_header: filename = f'{uuid.uuid4()}.alac' elif 'opus' in data_header: filename = f'{uuid.uuid4()}.opus' elif 'webm' in data_header: filename = f'{uuid.uuid4()}.webm' file_base64 = ele.base64.split(',')[-1] file_bytes = base64.b64decode(file_base64) elif ele.url: session = httpclient.get_session() async with session.get(ele.url) as response: file_bytes = await response.read() if file_bytes: files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) elif isinstance(ele, platform_message.File): file_bytes = None filename = f'{uuid.uuid4()}.{ele.name.split(".")[-1]}' if ele.base64: if ele.base64.startswith('data:'): file_base64 = ele.base64.split(',')[1] file_bytes = base64.b64decode(file_base64) else: file_bytes = base64.b64decode(ele.base64) elif ele.url: session = httpclient.get_session() async with session.get(ele.url) as response: file_bytes = await response.read() if file_bytes: files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) elif isinstance(ele, platform_message.Forward): for node in ele.node_list: ( node_text, node_files, ) = await DiscordMessageConverter.yiri2target(node.message_chain) text_string += node_text files.extend(node_files) return text_string, files @staticmethod async def target2yiri(message: discord.Message) -> platform_message.MessageChain: lb_msg_list = [] msg_create_time = datetime.datetime.fromtimestamp(int(message.created_at.timestamp())) lb_msg_list.append(platform_message.Source(id=message.id, time=msg_create_time)) element_list = [] def text_element_recur( text_ele: str, ) -> list[platform_message.MessageComponent]: if text_ele == '': return [] # <@1234567890> # @everyone # @here at_pattern = re.compile(r'(@everyone|@here|<@[\d]+>)') at_matches = at_pattern.findall(text_ele) if len(at_matches) > 0: mid_at = at_matches[0] text_split = text_ele.split(mid_at) mid_at_component = [] if mid_at == '@everyone' or mid_at == '@here': mid_at_component.append(platform_message.AtAll()) else: mid_at_component.append(platform_message.At(target=mid_at[2:-1])) return text_element_recur(text_split[0]) + mid_at_component + text_element_recur(text_split[1]) else: return [platform_message.Plain(text=text_ele)] element_list.extend(text_element_recur(message.content)) # attachments for attachment in message.attachments: session = httpclient.get_session(trust_env=True) async with session.get(attachment.url) as response: image_data = await response.read() image_base64 = base64.b64encode(image_data).decode('utf-8') image_format = response.headers['Content-Type'] element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}')) return platform_message.MessageChain(element_list) class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def yiri2target(event: platform_events.Event) -> discord.Message: pass @staticmethod async def target2yiri(event: discord.Message) -> platform_events.Event: message_chain = await DiscordMessageConverter.target2yiri(event) if isinstance(event.channel, discord.DMChannel): return platform_events.FriendMessage( sender=platform_entities.Friend( id=event.author.id, nickname=event.author.name, remark=event.channel.id, ), message_chain=message_chain, time=event.created_at.timestamp(), source_platform_object=event, ) elif isinstance(event.channel, discord.TextChannel): return platform_events.GroupMessage( sender=platform_entities.GroupMember( id=event.author.id, member_name=event.author.name, permission=platform_entities.Permission.Member, group=platform_entities.Group( id=event.channel.id, name=event.channel.name, permission=platform_entities.Permission.Member, ), special_title='', ), message_chain=message_chain, time=event.created_at.timestamp(), source_platform_object=event, ) class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: discord.Client = pydantic.Field(exclude=True) message_converter: DiscordMessageConverter = DiscordMessageConverter() event_converter: DiscordEventConverter = DiscordEventConverter() listeners: typing.Dict[ typing.Type[platform_events.Event], typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], ] = {} voice_manager: VoiceConnectionManager | None = pydantic.Field(exclude=True, default=None) def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): bot_account_id = config['client_id'] listeners = {} # 初始化语音连接管理器 # self.voice_manager: VoiceConnectionManager = None adapter_self = self class MyClient(discord.Client): async def on_message(self: discord.Client, message: discord.Message): if message.author.id == self.user.id or message.author.bot: return lb_event = await adapter_self.event_converter.target2yiri(message) await adapter_self.listeners[type(lb_event)](lb_event, adapter_self) intents = discord.Intents.default() intents.message_content = True args = {} if os.getenv('http_proxy'): args['proxy'] = os.getenv('http_proxy') bot = MyClient(intents=intents, **args) super().__init__( config=config, logger=logger, bot_account_id=bot_account_id, listeners=listeners, bot=bot, voice_manager=None, **kwargs, ) # Voice functionality methods async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient: """ 加入语音频道 为指定服务器的语音频道建立连接,支持用户权限验证和连接复用。 @author: @ydzat @version: 1.0 @since: 2025-07-04 Args: guild_id (int): Discord 服务器ID channel_id (int): 语音频道ID user_id (int, optional): 请求用户ID,用于权限验证 Returns: discord.VoiceClient: 语音客户端实例 Raises: VoicePermissionError: 权限不足 VoiceNetworkError: 网络连接失败 VoiceConnectionError: 其他连接错误 """ if not self.voice_manager: raise VoiceConnectionError('语音管理器未初始化', 'MANAGER_NOT_READY') return await self.voice_manager.join_voice_channel(guild_id, channel_id, user_id) async def leave_voice_channel(self, guild_id: int) -> bool: """ 离开语音频道 断开指定服务器的语音连接,清理相关资源。 @author: @ydzat @version: 1.0 @since: 2025-07-04 Args: guild_id (int): Discord 服务器ID Returns: bool: 是否成功断开连接 """ if not self.voice_manager: return False return await self.voice_manager.leave_voice_channel(guild_id) async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]: """ 获取语音客户端 返回指定服务器的语音客户端实例,用于音频播放控制。 @author: @ydzat @version: 1.0 @since: 2025-07-04 Args: guild_id (int): Discord 服务器ID Returns: Optional[discord.VoiceClient]: 语音客户端实例或 None """ if not self.voice_manager: return None return await self.voice_manager.get_voice_client(guild_id) async def is_connected_to_voice(self, guild_id: int) -> bool: """ 检查语音连接状态 @author: @ydzat @version: 1.0 @since: 2025-07-04 Args: guild_id (int): Discord 服务器ID Returns: bool: 是否已连接到语音频道 """ if not self.voice_manager: return False return await self.voice_manager.is_connected_to_voice(guild_id) async def get_voice_connection_status(self, guild_id: int) -> typing.Optional[dict]: """ 获取语音连接详细状态 返回包含连接时间、延迟、用户数等详细信息的状态字典。 @author: @ydzat @version: 1.0 @since: 2025-07-04 Args: guild_id (int): Discord 服务器ID Returns: Optional[dict]: 连接状态信息或 None """ if not self.voice_manager: return None return await self.voice_manager.get_connection_status(guild_id) async def list_active_voice_connections(self) -> typing.List[dict]: """ 列出所有活跃的语音连接 @author: @ydzat @version: 1.0 @since: 2025-07-04 Returns: List[dict]: 活跃语音连接列表 """ if not self.voice_manager: return [] return await self.voice_manager.list_active_connections() async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]: """ 获取语音频道详细信息 包括频道名称、用户列表、权限信息等。 @author: @ydzat @version: 1.0 @since: 2025-07-04 Args: guild_id (int): Discord 服务器ID channel_id (int): 语音频道ID Returns: Optional[dict]: 频道信息字典或 None """ if not self.voice_manager: return None return await self.voice_manager.get_voice_channel_info(guild_id, channel_id) async def cleanup_voice_connections(self): """ 清理无效的语音连接 手动触发语音连接清理,移除已断开或无效的连接。 @author: @ydzat @version: 1.0 @since: 2025-07-04 """ if self.voice_manager: await self.voice_manager.cleanup_inactive_connections() async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): msg_to_send, files = await self.message_converter.yiri2target(message) try: # 获取频道对象 channel = self.bot.get_channel(int(target_id)) if channel is None: # 如果本地缓存中没有,尝试从API获取 channel = await self.bot.fetch_channel(int(target_id)) args = { 'content': msg_to_send, } if len(files) > 0: args['files'] = files await channel.send(**args) except Exception as e: await self.logger.error(f'Discord send_message failed: {e}') raise e async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): msg_to_send, files = await self.message_converter.yiri2target(message) assert isinstance(message_source.source_platform_object, discord.Message) args = { 'content': msg_to_send, } if len(files) > 0: args['files'] = files if quote_origin: args['reference'] = message_source.source_platform_object has_at = False for component in message.root: if isinstance(component, platform_message.At): has_at = True break if has_at: args['mention_author'] = True await message_source.source_platform_object.channel.send(**args) async def is_muted(self, group_id: int) -> bool: return False def register_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): self.listeners[event_type] = callback def unregister_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): self.listeners.pop(event_type) async def run_async(self): """ 启动 Discord 适配器 初始化语音管理器并启动 Discord 客户端连接。 @author: @ydzat (修改) """ async with self.bot: # 初始化语音管理器 self.voice_manager = VoiceConnectionManager(self.bot, self.logger) await self.voice_manager.start_monitoring() await self.logger.info('Discord 适配器语音功能已启用') await self.bot.start(self.config['token'], reconnect=True) async def kill(self) -> bool: """ 关闭 Discord 适配器 清理语音连接并关闭 Discord 客户端。 @author: @ydzat (修改) """ if self.voice_manager: await self.voice_manager.disconnect_all() await self.bot.close() return True ================================================ FILE: src/langbot/pkg/platform/sources/discord.yaml ================================================ apiVersion: v1 kind: MessagePlatformAdapter metadata: name: discord label: en_US: Discord zh_Hans: Discord description: en_US: Discord Adapter zh_Hans: Discord 适配器,请查看文档了解使用方式 icon: discord.svg spec: config: - name: client_id label: en_US: Client ID zh_Hans: 客户端ID type: string required: true default: "" - name: token label: en_US: Token zh_Hans: 令牌 type: string required: true default: "" execution: python: path: ./discord.py attr: DiscordAdapter ================================================ FILE: src/langbot/pkg/platform/sources/kook.py ================================================ from __future__ import annotations import typing import asyncio import json import base64 import zlib import traceback import time import aiohttp from langbot.pkg.utils import httpclient import websockets import pydantic import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter): """Convert between LangBot MessageChain and KOOK message format""" @staticmethod async def yiri2target(message_chain: platform_message.MessageChain) -> tuple[str, int]: """ Convert LangBot MessageChain to KOOK message format Returns: tuple: (content, message_type) - content: message content string - message_type: 1=text, 2=image, 4=file, 9=KMarkdown """ content_parts = [] message_type = 1 # Default to text for component in message_chain: if isinstance(component, platform_message.Plain): content_parts.append(component.text) elif isinstance(component, platform_message.At): # KOOK mention format: (met)user_id(met) if component.target: content_parts.append(f'(met){component.target}(met)') elif isinstance(component, platform_message.AtAll): # KOOK @all format: (met)all(met) content_parts.append('(met)all(met)') elif isinstance(component, platform_message.Image): # For images, we need to upload first via KOOK's asset API # For now, we'll send the image URL if available if component.url: content_parts.append(component.url) message_type = 2 # Image message type elif isinstance(component, platform_message.Forward): # Handle forward messages by concatenating content for node in component.node_list: forward_content, _ = await KookMessageConverter.yiri2target(node.message_chain) content_parts.append(forward_content) # Ignore Source and other components content = ''.join(content_parts) return content, message_type @staticmethod async def target2yiri(kook_message: dict, bot_account_id: str = '') -> platform_message.MessageChain: """ Convert KOOK message format to LangBot MessageChain Args: kook_message: KOOK message event data dict bot_account_id: Bot's account ID for handling role mentions """ components = [] msg_type = kook_message.get('type', 1) content = kook_message.get('content', '') extra = kook_message.get('extra', {}) # Handle mentions mentions = extra.get('mention', []) mention_all = extra.get('mention_all', False) mention_roles = extra.get('mention_roles', []) if mention_all: components.append(platform_message.AtAll()) for mention_id in mentions: components.append(platform_message.At(target=str(mention_id))) # Handle role mentions (when bot is mentioned via role) # In KOOK, when a role that the bot has is mentioned, we receive it as a role mention # We need to convert this to an At with the bot's account ID for the pipeline to recognize it if mention_roles and bot_account_id: # Add an At component with the bot's account ID when any role is mentioned # This is because KOOK bots are often assigned roles and @role mentions should trigger responses components.append(platform_message.At(target=bot_account_id)) # Strip mention patterns from content # Remove user mention patterns: (met)USER_ID(met) for mention_id in mentions: content = content.replace(f'(met){mention_id}(met)', '') # Remove @all pattern if mention_all: content = content.replace('(met)all(met)', '') # Remove role mention patterns: (rol)ROLE_ID(rol) for role_id in mention_roles: content = content.replace(f'(rol){role_id}(rol)', '') # Clean up extra whitespace content = content.strip() # Handle different message types if msg_type == 1: # Text message if content: components.append(platform_message.Plain(text=content)) elif msg_type == 2: # Image message # Image content is typically a URL if content: # Download image and convert to base64 try: session = httpclient.get_session() async with session.get(content) as response: if response.status == 200: image_bytes = await response.read() image_base64 = base64.b64encode(image_bytes).decode('utf-8') # Detect image format content_type = response.headers.get('Content-Type', 'image/png') components.append( platform_message.Image(base64=f'data:{content_type};base64,{image_base64}') ) except Exception: # If download fails, just add as plain text components.append(platform_message.Plain(text=f'[Image: {content}]')) elif msg_type == 4: # File message # For file messages, content is typically the file URL attachments = extra.get('attachments', {}) file_name = attachments.get('name', 'file') components.append(platform_message.File(url=content, name=file_name)) elif msg_type == 8: # Audio message # For audio messages, content is typically the audio URL attachments = extra.get('attachments', {}) components.append(platform_message.Voice(url=content)) elif msg_type == 9: # KMarkdown message # Note: content is already stripped of mention patterns above if content: components.append(platform_message.Plain(text=content)) elif msg_type == 10: # Card message # Card messages are complex, for now just indicate it's a card components.append(platform_message.Plain(text='[Card Message]')) else: # Other message types, just use content as plain text if content: components.append(platform_message.Plain(text=content)) return platform_message.MessageChain(components) class KookEventConverter(abstract_platform_adapter.AbstractEventConverter): """Convert between LangBot events and KOOK events""" @staticmethod async def yiri2target(event: platform_events.MessageEvent): """Convert LangBot event to KOOK event (not implemented)""" pass @staticmethod async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.MessageEvent: """ Convert KOOK event to LangBot MessageEvent Args: kook_event: KOOK event data dict containing channel_type, type, etc. bot_account_id: Bot's account ID for handling role mentions Returns: FriendMessage or GroupMessage depending on channel_type """ channel_type = kook_event.get('channel_type') author_id = kook_event.get('author_id') target_id = kook_event.get('target_id') msg_timestamp = kook_event.get('msg_timestamp', int(time.time() * 1000)) extra = kook_event.get('extra', {}) # Convert message to MessageChain message_chain = await KookMessageConverter.target2yiri(kook_event, bot_account_id) # Convert timestamp from milliseconds to seconds event_time = msg_timestamp / 1000.0 if channel_type == 'PERSON': # Direct/Private message author = extra.get('author', {}) author_name = author.get('nickname', author.get('username', str(author_id))) return platform_events.FriendMessage( sender=platform_entities.Friend( id=str(author_id), nickname=author_name, remark=str(author_id), ), message_chain=message_chain, time=event_time, source_platform_object=kook_event, ) elif channel_type == 'GROUP': # Guild/Server channel message author = extra.get('author', {}) author_name = author.get('nickname', author.get('username', str(author_id))) # guild_id = extra.get('guild_id', '') channel_name = extra.get('channel_name', str(target_id)) return platform_events.GroupMessage( sender=platform_entities.GroupMember( id=str(author_id), member_name=author_name, permission=platform_entities.Permission.Member, group=platform_entities.Group( id=str(target_id), # Channel ID name=channel_name, permission=platform_entities.Permission.Member, ), special_title='', ), message_chain=message_chain, time=event_time, source_platform_object=kook_event, ) else: # Fallback to FriendMessage for unknown channel types return platform_events.FriendMessage( sender=platform_entities.Friend( id=str(author_id), nickname=str(author_id), remark=str(author_id), ), message_chain=message_chain, time=event_time, source_platform_object=kook_event, ) class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): """KOOK platform adapter for LangBot""" config: dict message_converter: KookMessageConverter = KookMessageConverter() event_converter: KookEventConverter = KookEventConverter() listeners: typing.Dict[ typing.Type[platform_events.Event], typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], ] = {} # WebSocket connection ws: typing.Optional[websockets.WebSocketClientProtocol] = pydantic.Field(exclude=True, default=None) ws_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None) heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None) running: bool = pydantic.Field(exclude=True, default=False) # Connection state session_id: str = pydantic.Field(exclude=True, default='') current_sn: int = pydantic.Field(exclude=True, default=0) gateway_url: str = pydantic.Field(exclude=True, default='') # HTTP session http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None) def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): # Debug: Track init with open('/tmp/kook_adapter_init.txt', 'w') as f: f.write(f'KOOK adapter __init__ called at {time.time()}\n') # Validate required config if 'token' not in config: raise Exception('KOOK adapter requires "token" in config') super().__init__( config=config, logger=logger, bot_account_id='', # Will be set after connection listeners={}, **kwargs, ) async def _get_gateway_url(self) -> str: """Get WebSocket gateway URL from KOOK API""" base_url = 'https://www.kookapp.cn/api/v3/gateway/index' # Always use compression for better performance params = {'compress': 1} headers = { 'Authorization': f'Bot {self.config["token"]}', } session = httpclient.get_session() async with session.get(base_url, params=params, headers=headers) as response: if response.status == 200: data = await response.json() if data.get('code') == 0: gateway_url = data['data']['url'] return gateway_url else: raise Exception(f'Failed to get gateway URL: {data.get("message")}') else: raise Exception(f'Failed to get gateway URL: HTTP {response.status}') async def _get_bot_user_info(self) -> dict: """Get bot's own user information from KOOK API""" base_url = 'https://www.kookapp.cn/api/v3/user/me' headers = { 'Authorization': f'Bot {self.config["token"]}', } session = httpclient.get_session() async with session.get(base_url, headers=headers) as response: if response.status == 200: data = await response.json() if data.get('code') == 0: user_info = data['data'] return user_info else: raise Exception(f'Failed to get bot user info: {data.get("message")}') else: raise Exception(f'Failed to get bot user info: HTTP {response.status}') async def _handle_hello(self, data: dict): """Handle HELLO signal (signal 1)""" session_id = data.get('session_id', '') self.session_id = session_id await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {session_id}') async def _handle_event(self, data: dict, sn: int): """Handle EVENT signal (signal 0)""" self.current_sn = max(self.current_sn, sn) # Check if this is a message event event_type = data.get('type') channel_type = data.get('channel_type') author_id = data.get('author_id') # Ignore messages from bot itself to prevent infinite loops if self.bot_account_id and str(author_id) == self.bot_account_id: return # Only process text messages (type 1, 2, 4, 8, 9, 10) in GROUP or PERSON channels if event_type in [1, 2, 4, 8, 9, 10] and channel_type in ['GROUP', 'PERSON']: try: # Convert to LangBot event lb_event = await self.event_converter.target2yiri(data, self.bot_account_id) # Call registered listener event_class = type(lb_event) if event_class in self.listeners: await self.listeners[event_class](lb_event, self) except Exception as e: await self.logger.error(f'Error handling KOOK event: {e}\n{traceback.format_exc()}') async def _handle_pong(self, data: dict): """Handle PONG signal (signal 3)""" # PONG received, connection is healthy pass async def _heartbeat_loop(self): """Send PING every 30 seconds""" try: while self.running and self.ws: await asyncio.sleep(30) if self.ws: try: ping_msg = { 's': 2, # PING signal 'sn': self.current_sn, } await self.ws.send(json.dumps(ping_msg)) except Exception: # Connection closed or send failed, exit loop break except asyncio.CancelledError: pass except Exception as e: await self.logger.error(f'Heartbeat error: {e}') async def _websocket_loop(self): """Main WebSocket event loop""" retry_count = 0 max_retries = 3 while self.running and retry_count < max_retries: try: # Get gateway URL if not already retrieved if not self.gateway_url: self.gateway_url = await self._get_gateway_url() # Connect to WebSocket async with websockets.connect(self.gateway_url) as ws: await self.logger.info(f'Connected to KOOK WebSocket: {self.gateway_url}') self.ws = ws # Start heartbeat self.heartbeat_task = asyncio.create_task(self._heartbeat_loop()) # Wait for HELLO within 6 seconds try: hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0) # Handle compressed messages (same as main message loop) if isinstance(hello_msg, bytes): # Decompress if compressed try: hello_msg = zlib.decompress(hello_msg).decode('utf-8') except Exception: # Not compressed or decompression failed hello_msg = hello_msg.decode('utf-8') hello_data = json.loads(hello_msg) if hello_data.get('s') == 1: # HELLO signal await self._handle_hello(hello_data['d']) else: raise Exception(f'Expected HELLO signal, got signal {hello_data.get("s")}') except asyncio.TimeoutError: raise Exception('Did not receive HELLO within 6 seconds') # Reset retry count on successful connection retry_count = 0 # Main message loop async for message in ws: if isinstance(message, bytes): # Decompress if compressed try: message = zlib.decompress(message).decode('utf-8') except Exception: # Not compressed or decompression failed message = message.decode('utf-8') try: msg_data = json.loads(message) signal = msg_data.get('s') if signal == 0: # EVENT data = msg_data.get('d', {}) sn = msg_data.get('sn', 0) await self._handle_event(data, sn) elif signal == 3: # PONG await self._handle_pong(msg_data.get('d', {})) elif signal == 5: # RECONNECT # await self.logger.info('Received RECONNECT signal') break # Break to reconnect elif signal == 6: # RESUME ACK # await self.logger.info('Resume successful') pass except json.JSONDecodeError: await self.logger.error(f'Failed to parse message: {message}') except Exception as e: await self.logger.error(f'Error processing message: {e}\n{traceback.format_exc()}') except websockets.exceptions.ConnectionClosed: await self.logger.warning('KOOK WebSocket connection closed, reconnecting...') retry_count += 1 await asyncio.sleep(2**retry_count) # Exponential backoff except Exception as e: await self.logger.error(f'KOOK WebSocket error: {e}\n{traceback.format_exc()}') retry_count += 1 await asyncio.sleep(2**retry_count) finally: # Stop heartbeat if self.heartbeat_task: self.heartbeat_task.cancel() try: await self.heartbeat_task except asyncio.CancelledError: pass self.ws = None if retry_count >= max_retries: await self.logger.error(f'Failed to connect after {max_retries} retries') async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): """Send a message to a channel or user""" content, msg_type = await self.message_converter.yiri2target(message) # Determine endpoint based on target_type if target_type == 'GROUP': # Send to channel url = 'https://www.kookapp.cn/api/v3/message/create' payload = { 'target_id': target_id, 'content': content, 'type': msg_type, } else: # PERSON or default # Send direct message url = 'https://www.kookapp.cn/api/v3/direct-message/create' payload = { 'target_id': target_id, 'content': content, 'type': msg_type, } headers = { 'Authorization': f'Bot {self.config["token"]}', 'Content-Type': 'application/json', } try: if not self.http_session: self.http_session = httpclient.get_session() async with self.http_session.post(url, json=payload, headers=headers) as response: if response.status == 200: result = await response.json() if result.get('code') == 0: await self.logger.debug(f'Message sent successfully to {target_id}') else: await self.logger.error(f'Failed to send message: {result.get("message")}') else: await self.logger.error(f'Failed to send message: HTTP {response.status}') except Exception as e: await self.logger.error(f'Error sending message: {e}') async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): """Reply to a message""" content, msg_type = await self.message_converter.yiri2target(message) kook_event = message_source.source_platform_object channel_type = kook_event.get('channel_type') target_id = kook_event.get('target_id') msg_id = kook_event.get('msg_id') # Determine endpoint based on channel_type if channel_type == 'GROUP': url = 'https://www.kookapp.cn/api/v3/message/create' payload = { 'target_id': target_id, 'content': content, 'type': msg_type, } else: # PERSON url = 'https://www.kookapp.cn/api/v3/direct-message/create' # For direct messages, we need the chat_code or target_id author_id = kook_event.get('author_id') extra = kook_event.get('extra', {}) chat_code = extra.get('code', '') payload = { 'content': content, 'type': msg_type, } if chat_code: payload['chat_code'] = chat_code else: payload['target_id'] = str(author_id) # Add quote if requested if quote_origin and msg_id: payload['quote'] = msg_id payload['reply_msg_id'] = msg_id headers = { 'Authorization': f'Bot {self.config["token"]}', 'Content-Type': 'application/json', } try: if not self.http_session: self.http_session = httpclient.get_session() async with self.http_session.post(url, json=payload, headers=headers) as response: if response.status == 200: result = await response.json() if result.get('code') == 0: await self.logger.debug('Reply sent successfully') else: await self.logger.error(f'Failed to send reply: {result.get("message")}') else: await self.logger.error(f'Failed to send reply: HTTP {response.status}') except Exception as e: await self.logger.error(f'Error sending reply: {e}') async def is_muted(self, group_id: int) -> bool: """Check if bot is muted in a group (not implemented for KOOK)""" return False def register_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): """Register an event listener""" self.listeners[event_type] = callback def unregister_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): """Unregister an event listener""" self.listeners.pop(event_type, None) async def run_async(self): """Start the KOOK adapter""" # Debug: Track run_async with open('/tmp/kook_adapter_run.txt', 'w') as f: f.write(f'KOOK adapter run_async called at {time.time()}\n') self.running = True try: # Create HTTP session self.http_session = httpclient.get_session() await self.logger.info('Starting KOOK adapter') # Get bot's user information and set bot_account_id try: bot_info = await self._get_bot_user_info() self.bot_account_id = str(bot_info.get('id', '')) except Exception as e: await self.logger.error(f'Failed to get bot user info: {e}') # Continue anyway, but bot will process its own messages # Start WebSocket connection self.ws_task = asyncio.create_task(self._websocket_loop()) # Keep running await self.ws_task except Exception as e: await self.logger.error(f'KOOK adapter error: {e}\n{traceback.format_exc()}') finally: self.running = False async def kill(self) -> bool: """Stop the KOOK adapter""" self.running = False # Cancel tasks if self.heartbeat_task: self.heartbeat_task.cancel() try: await self.heartbeat_task except asyncio.CancelledError: pass if self.ws_task: self.ws_task.cancel() try: await self.ws_task except asyncio.CancelledError: pass # Close WebSocket if self.ws: try: await self.ws.close() except Exception: pass # Already closed or error during close # Close HTTP session if self.http_session: await self.http_session.close() await self.logger.info('KOOK adapter stopped') return True ================================================ FILE: src/langbot/pkg/platform/sources/kook.yaml ================================================ apiVersion: v1 kind: MessagePlatformAdapter metadata: name: kook label: en_US: KOOK zh_Hans: KOOK description: en_US: KOOK Adapter (formerly KaiHeiLa) zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息 icon: kook.png spec: config: - name: token label: en_US: Bot Token zh_Hans: 机器人令牌 type: string required: true default: "" execution: python: path: ./kook.py attr: KookAdapter ================================================ FILE: src/langbot/pkg/platform/sources/lark.py ================================================ from __future__ import annotations import lark_oapi from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody, CreateFileRequest, CreateFileRequestBody import traceback import typing import asyncio import re import base64 import uuid import json import time import datetime import hashlib from Crypto.Cipher import AES import tempfile import os import mimetypes from langbot.pkg.utils import httpclient import lark_oapi.ws.exception import quart from lark_oapi.api.im.v1 import * import pydantic from lark_oapi.api.cardkit.v1 import * from lark_oapi.api.auth.v3 import * from lark_oapi.core.model import * import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger class AESCipher(object): def __init__(self, key): self.bs = AES.block_size self.key = hashlib.sha256(AESCipher.str_to_bytes(key)).digest() @staticmethod def str_to_bytes(data): u_type = type(b''.decode('utf8')) if isinstance(data, u_type): return data.encode('utf8') return data @staticmethod def _unpad(s): return s[: -ord(s[len(s) - 1 :])] def decrypt(self, enc): iv = enc[: AES.block_size] cipher = AES.new(self.key, AES.MODE_CBC, iv) return self._unpad(cipher.decrypt(enc[AES.block_size :])) def decrypt_string(self, enc): enc = base64.b64decode(enc) return self.decrypt(enc).decode('utf8') class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> typing.Optional[str]: """Upload an image to Lark and return the image_key, or None if upload fails.""" image_bytes = None if msg.base64: try: # Remove data URL prefix if present base64_data = msg.base64 if base64_data.startswith('data:'): base64_data = base64_data.split(',', 1)[1] image_bytes = base64.b64decode(base64_data) except Exception as e: print(f'Failed to decode base64 image: {e}') traceback.print_exc() return None elif msg.url: try: session = httpclient.get_session() async with session.get(msg.url) as response: if response.status == 200: image_bytes = await response.read() else: print(f'Failed to download image from {msg.url}: HTTP {response.status}') return None except Exception as e: print(f'Failed to download image from {msg.url}: {e}') traceback.print_exc() return None elif msg.path: try: with open(msg.path, 'rb') as f: image_bytes = f.read() except Exception as e: print(f'Failed to read image from path {msg.path}: {e}') traceback.print_exc() return None if image_bytes is None: print( f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})' ) return None try: # Create a temporary file to store the image bytes import tempfile import os with tempfile.NamedTemporaryFile(delete=False) as temp_file: temp_file.write(image_bytes) temp_file.flush() temp_file_path = temp_file.name try: # Create image request using the temporary file request = ( CreateImageRequest.builder() .request_body( CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build() ) .build() ) response = await api_client.im.v1.image.acreate(request) if not response.success(): print( f'client.im.v1.image.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}' ) return None return response.data.image_key finally: # Clean up the temporary file os.unlink(temp_file_path) except Exception as e: print(f'Failed to upload image to Lark: {e}') traceback.print_exc() return None @staticmethod async def upload_file_to_lark( file_bytes: bytes, api_client: lark_oapi.Client, file_type: str, file_name: str = 'file', duration: typing.Optional[int] = None, ) -> typing.Optional[str]: """Upload a file to Lark and return the file_key, or None if upload fails. Args: file_bytes: Raw file bytes. api_client: Lark API client. file_type: Lark file type, e.g. 'opus', 'mp4', 'pdf', 'doc', etc. file_name: Display name for the file. duration: Duration in milliseconds (for audio files). """ try: with tempfile.NamedTemporaryFile(delete=False) as temp_file: temp_file.write(file_bytes) temp_file_path = temp_file.name try: body_builder = ( CreateFileRequestBody.builder() .file_type(file_type) .file_name(file_name) .file(open(temp_file_path, 'rb')) ) if duration is not None: body_builder = body_builder.duration(duration) request = CreateFileRequest.builder().request_body(body_builder.build()).build() response = await api_client.im.v1.file.acreate(request) if not response.success(): print( f'client.im.v1.file.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}' ) return None return response.data.file_key finally: os.unlink(temp_file_path) except Exception as e: print(f'Failed to upload file to Lark: {e}') traceback.print_exc() return None @staticmethod async def _get_media_bytes( msg: typing.Union[platform_message.Voice, platform_message.File], ) -> typing.Optional[bytes]: """Get bytes from a Voice or File message (base64, url, or path).""" data = None if msg.base64: try: base64_str = msg.base64 if ',' in base64_str: base64_str = base64_str.split(',', 1)[1] data = base64.b64decode(base64_str) except Exception: pass elif msg.url: try: session = httpclient.get_session() async with session.get(msg.url) as resp: if resp.status == 200: data = await resp.read() except Exception: pass elif msg.path: try: with open(msg.path, 'rb') as f: data = f.read() except Exception: pass return data @staticmethod async def yiri2target( message_chain: platform_message.MessageChain, api_client: lark_oapi.Client ) -> typing.Tuple[list, list]: """Convert message chain to Lark format. Returns: Tuple of (text_elements, image_keys): - text_elements: List of paragraphs for post message format - media_items: List of dicts with 'msg_type' and 'content' for separate media messages """ message_elements = [] media_items = [] pending_paragraph = [] # Regex pattern to match Markdown image syntax: ![alt](url) markdown_image_pattern = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') async def process_text_with_images(text: str) -> typing.Tuple[str, list]: """Extract Markdown images from text and return cleaned text + image URLs.""" extracted_urls = [] # Find all Markdown images matches = list(markdown_image_pattern.finditer(text)) if not matches: return text, [] # Extract URLs and remove image syntax from text cleaned_text = text for match in reversed(matches): # Reverse to maintain correct positions url = match.group(2) extracted_urls.insert(0, url) # Insert at beginning since we're going in reverse # Replace image syntax with empty string or a placeholder cleaned_text = cleaned_text[: match.start()] + cleaned_text[match.end() :] # Clean up multiple consecutive newlines that might result from removing images cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text) cleaned_text = cleaned_text.strip() return cleaned_text, extracted_urls for msg in message_chain: if isinstance(msg, platform_message.Plain): # Ensure text is valid UTF-8 try: text = msg.text.encode('utf-8').decode('utf-8') except UnicodeError: try: text = msg.text.encode('latin1').decode('utf-8') except UnicodeError: text = msg.text.encode('utf-8', errors='replace').decode('utf-8') # Check for and extract Markdown images from text cleaned_text, extracted_urls = await process_text_with_images(text) # Split by blank lines to create separate paragraphs for Lark post format. # Lark truncates md elements at the first \n\n, so we must use the # post format's native paragraph structure instead. if cleaned_text: segments = re.split(r'\n\s*\n', cleaned_text) for i, segment in enumerate(segments): segment = segment.strip() if not segment: continue if i > 0 and pending_paragraph: message_elements.append(pending_paragraph) pending_paragraph = [] pending_paragraph.append({'tag': 'md', 'text': segment}) # Process extracted image URLs for url in extracted_urls: temp_image = platform_message.Image(url=url) image_key = await LarkMessageConverter.upload_image_to_lark(temp_image, api_client) if image_key: media_items.append({'msg_type': 'image', 'content': {'image_key': image_key}}) elif isinstance(msg, platform_message.At): pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []}) elif isinstance(msg, platform_message.AtAll): pending_paragraph.append({'tag': 'at', 'user_id': 'all', 'style': []}) elif isinstance(msg, platform_message.Image): image_key = await LarkMessageConverter.upload_image_to_lark(msg, api_client) if image_key: media_items.append({'msg_type': 'image', 'content': {'image_key': image_key}}) elif isinstance(msg, platform_message.Voice): data = await LarkMessageConverter._get_media_bytes(msg) if data: duration = int(msg.length * 1000) if msg.length else None file_key = await LarkMessageConverter.upload_file_to_lark( data, api_client, file_type='opus', file_name='voice.opus', duration=duration ) if file_key: media_items.append({'msg_type': 'audio', 'content': {'file_key': file_key}}) elif isinstance(msg, platform_message.File): data = await LarkMessageConverter._get_media_bytes(msg) if data: file_name = msg.name or 'file' # Guess file_type from extension ext = os.path.splitext(file_name)[1].lstrip('.').lower() if file_name else '' file_type_map = { 'opus': 'opus', 'mp4': 'mp4', 'pdf': 'pdf', 'doc': 'doc', 'docx': 'doc', 'xls': 'xls', 'xlsx': 'xls', 'ppt': 'ppt', 'pptx': 'ppt', } file_type = file_type_map.get(ext, 'stream') file_key = await LarkMessageConverter.upload_file_to_lark( data, api_client, file_type=file_type, file_name=file_name ) if file_key: media_items.append({'msg_type': 'file', 'content': {'file_key': file_key}}) elif isinstance(msg, platform_message.Forward): for node in msg.node_list: sub_elements, sub_media = await LarkMessageConverter.yiri2target(node.message_chain, api_client) message_elements.extend(sub_elements) media_items.extend(sub_media) if pending_paragraph: message_elements.append(pending_paragraph) return message_elements, media_items @staticmethod async def target2yiri( message: lark_oapi.api.im.v1.model.event_message.EventMessage, api_client: lark_oapi.Client, ) -> platform_message.MessageChain: message_content = json.loads(message.content) lb_msg_list = [] msg_create_time = datetime.datetime.fromtimestamp(int(message.create_time) / 1000) lb_msg_list.append(platform_message.Source(id=message.message_id, time=msg_create_time)) if message.message_type == 'text': element_list = [] def text_element_recur(text_ele: dict) -> list[dict]: if text_ele['text'] == '': return [] at_pattern = re.compile(r'@_user_[\d]+') at_matches = at_pattern.findall(text_ele['text']) name_mapping = {} for mathc in at_matches: for mention in message.mentions: if mention.key == mathc: name_mapping[mathc] = mention.name break if len(name_mapping.keys()) == 0: return [text_ele] # 只处理第一个,剩下的递归处理 text_split = text_ele['text'].split(list(name_mapping.keys())[0]) new_list = [] left_text = text_split[0] right_text = text_split[1] new_list.extend(text_element_recur({'tag': 'text', 'text': left_text, 'style': []})) new_list.append( { 'tag': 'at', 'user_id': list(name_mapping.keys())[0], 'user_name': name_mapping[list(name_mapping.keys())[0]], 'style': [], } ) new_list.extend(text_element_recur({'tag': 'text', 'text': right_text, 'style': []})) return new_list element_list = text_element_recur({'tag': 'text', 'text': message_content['text'], 'style': []}) message_content = {'title': '', 'content': element_list} elif message.message_type == 'post': new_list = [] for ele in message_content['content']: if type(ele) is dict: new_list.append(ele) elif type(ele) is list: new_list.extend(ele) message_content['content'] = new_list elif message.message_type == 'image': message_content['content'] = [{'tag': 'img', 'image_key': message_content['image_key'], 'style': []}] elif message.message_type == 'file': message_content['content'] = [ {'tag': 'file', 'file_key': message_content['file_key'], 'file_name': message_content['file_name']} ] elif message.message_type == 'audio': message_content['content'] = [ { 'tag': 'audio', 'file_key': message_content['file_key'], 'duration': message_content.get('duration', 0), } ] for ele in message_content['content']: if ele['tag'] == 'text': lb_msg_list.append(platform_message.Plain(text=ele['text'])) elif ele['tag'] == 'at': lb_msg_list.append(platform_message.At(target=ele['user_name'])) elif ele['tag'] == 'img': image_key = ele['image_key'] request: GetMessageResourceRequest = ( GetMessageResourceRequest.builder() .message_id(message.message_id) .file_key(image_key) .type('image') .build() ) response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request) if not response.success(): raise Exception( f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) image_bytes = response.file.read() image_base64 = base64.b64encode(image_bytes).decode() image_format = response.raw.headers['content-type'] lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}')) elif ele['tag'] == 'audio': file_key = ele['file_key'] duration = ele['duration'] # Download audio file request: GetMessageResourceRequest = ( GetMessageResourceRequest.builder() .message_id(message.message_id) .file_key(file_key) .type('file') .build() ) try: response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request) if not response.success(): print(f'Failed to download audio: code: {response.code}, msg: {response.msg}') lb_msg_list.append(platform_message.Plain(text='[Audio file download failed]')) return platform_message.MessageChain(lb_msg_list) # Read audio bytes audio_bytes = response.file.read() audio_base64 = base64.b64encode(audio_bytes).decode() # Get content type from response headers content_type = response.raw.headers.get('content-type', 'audio/mpeg') mime_main = content_type.split(';')[0].strip() ext = mimetypes.guess_extension(mime_main) or '.bin' temp_dir = tempfile.gettempdir() temp_file_path = os.path.join(temp_dir, f'lark_audio_{file_key}{ext}') with open(temp_file_path, 'wb') as f: f.write(audio_bytes) # Create Voice message: prefer path/url + length, include base64 as optional data URI lb_msg_list.append( platform_message.Voice( voice_id=file_key, url=f'file://{temp_file_path}', path=temp_file_path, base64=f'data:{content_type};base64,{audio_base64}', length=(duration // 1000) if duration else None, ) ) except Exception as e: print(f'Error downloading audio: {e}') traceback.print_exc() lb_msg_list.append(platform_message.Plain(text='[Audio file download error]')) elif ele['tag'] == 'file': file_key = ele['file_key'] file_name = ele['file_name'] request: GetMessageResourceRequest = ( GetMessageResourceRequest.builder() .message_id(message.message_id) .file_key(file_key) .type('file') .build() ) response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request) if not response.success(): raise Exception( f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) file_bytes = response.file.read() file_base64 = base64.b64encode(file_bytes).decode() file_format = response.raw.headers['content-type'] file_size = len(file_bytes) # Determine extension from content-type if possible content_type = response.raw.headers.get('content-type', '') mime_main = content_type.split(';')[0].strip() if content_type else '' ext = mimetypes.guess_extension(mime_main) or '' # Ensure a safe filename (avoid path components) safe_name = os.path.basename(file_name).replace('/', '_').replace('\\', '_') if ext and not safe_name.lower().endswith(ext.lower()): filename_with_ext = f'{safe_name}{ext}' else: filename_with_ext = safe_name temp_dir = tempfile.gettempdir() temp_file_path = os.path.join(temp_dir, f'lark_{file_key}_{filename_with_ext}') with open(temp_file_path, 'wb') as f: f.write(file_bytes) # Create File message with local path and file:// URL lb_msg_list.append( platform_message.File( id=file_key, name=file_name, size=file_size, url=f'file://{temp_file_path}', path=temp_file_path, base64=f'data:{file_format};base64,{file_base64}', # not including base64 by default to save memory; can be added if needed ) ) return platform_message.MessageChain(lb_msg_list) class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter): _processed_thread_quote_cache: typing.ClassVar[dict[str, float]] = {} _processed_thread_quote_cache_max_size: typing.ClassVar[int] = 4096 _processed_thread_quote_cache_ttl_seconds: typing.ClassVar[int] = 86400 @classmethod def _prune_processed_thread_quote_cache(cls, now: typing.Optional[float] = None) -> None: if now is None: now = time.time() expire_before = now - cls._processed_thread_quote_cache_ttl_seconds while cls._processed_thread_quote_cache: oldest_key, oldest_ts = next(iter(cls._processed_thread_quote_cache.items())) if oldest_ts >= expire_before: break cls._processed_thread_quote_cache.pop(oldest_key, None) while len(cls._processed_thread_quote_cache) > cls._processed_thread_quote_cache_max_size: oldest_key = next(iter(cls._processed_thread_quote_cache)) cls._processed_thread_quote_cache.pop(oldest_key, None) @classmethod def _mark_thread_quote_processed(cls, thread_id: str) -> None: now = time.time() cls._prune_processed_thread_quote_cache(now) cls._processed_thread_quote_cache[thread_id] = now @classmethod def _extract_quote_message_id(cls, message: EventMessage) -> typing.Optional[str]: """ Extract the message ID to quote from the given message. Rules: - First thread reply in a topic: return parent_id and mark topic as processed - Follow-up thread replies in the same topic: return None - Non-thread message: return parent_id if valid (non-empty, different from message_id) Thread reply state is kept in a bounded TTL cache to avoid unbounded memory growth. """ parent_id = getattr(message, 'parent_id', None) if not parent_id: return None message_id = getattr(message, 'message_id', None) if parent_id == message_id: return None thread_id = getattr(message, 'thread_id', None) if thread_id: cls._prune_processed_thread_quote_cache() if thread_id in cls._processed_thread_quote_cache: return None cls._mark_thread_quote_processed(thread_id) return parent_id @staticmethod def _build_event_message_from_message_item(message_item: Message) -> typing.Optional[EventMessage]: """ Build EventMessage from SDK typed Message item. Returns None if body or content is missing. """ body = getattr(message_item, 'body', None) if not body: return None content = getattr(body, 'content', None) if not content: return None event_data = { 'message_id': message_item.message_id, 'message_type': message_item.msg_type, 'content': content, 'create_time': message_item.create_time, 'mentions': getattr(message_item, 'mentions', []) or [], } # Preserve thread-related fields if hasattr(message_item, 'parent_id') and message_item.parent_id: event_data['parent_id'] = message_item.parent_id if hasattr(message_item, 'root_id') and message_item.root_id: event_data['root_id'] = message_item.root_id if hasattr(message_item, 'thread_id') and message_item.thread_id: event_data['thread_id'] = message_item.thread_id if hasattr(message_item, 'chat_id') and message_item.chat_id: event_data['chat_id'] = message_item.chat_id return EventMessage(event_data) @staticmethod async def _fetch_quoted_message( quote_message_id: str, api_client: lark_oapi.Client, ) -> typing.Optional[platform_message.MessageChain]: """ Fetch the quoted message and convert to MessageChain. Returns None if: - API call fails - Response items is empty - Message item normalization fails """ request = GetMessageRequest.builder().message_id(quote_message_id).build() response = await api_client.im.v1.message.aget(request) if not response.success(): return None items = getattr(response.data, 'items', None) if not items: return None message_item = items[0] event_message = LarkEventConverter._build_event_message_from_message_item(message_item) if event_message is None: return None quote_chain = await LarkMessageConverter.target2yiri(event_message, api_client) return quote_chain @staticmethod async def yiri2target( event: platform_events.MessageEvent, ) -> lark_oapi.im.v1.P2ImMessageReceiveV1: pass @staticmethod async def target2yiri( event: lark_oapi.im.v1.P2ImMessageReceiveV1, api_client: lark_oapi.Client ) -> platform_events.Event: message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client) # Check for quote/reply message quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message) if quote_message_id: quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client) if quote_chain: # Filter out Source component from quoted chain, keep only content quote_origin = platform_message.MessageChain( [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)] ) if quote_origin: message_chain.append( platform_message.Quote( message_id=quote_message_id, origin=quote_origin, ) ) if event.event.message.chat_type == 'p2p': return platform_events.FriendMessage( sender=platform_entities.Friend( id=event.event.sender.sender_id.open_id, nickname=event.event.sender.sender_id.union_id, remark='', ), message_chain=message_chain, time=event.event.message.create_time, source_platform_object=event, ) elif event.event.message.chat_type == 'group': return platform_events.GroupMessage( sender=platform_entities.GroupMember( id=event.event.sender.sender_id.open_id, member_name=event.event.sender.sender_id.union_id, permission=platform_entities.Permission.Member, group=platform_entities.Group( id=event.event.message.chat_id, name='', permission=platform_entities.Permission.Member, ), special_title='', ), message_chain=message_chain, time=event.event.message.create_time, source_platform_object=event, ) CARD_ID_CACHE_SIZE = 500 CARD_ID_CACHE_MAX_LIFETIME = 20 * 60 # 20分钟 class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: lark_oapi.ws.Client = pydantic.Field(exclude=True) api_client: lark_oapi.Client = pydantic.Field(exclude=True) bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识 lark_tenant_key: str = pydantic.Field(exclude=True, default='') # 飞书企业key message_converter: LarkMessageConverter = LarkMessageConverter() event_converter: LarkEventConverter = LarkEventConverter() cipher: AESCipher listeners: typing.Dict[ typing.Type[platform_events.Event], typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], ] quart_app: quart.Quart = pydantic.Field(exclude=True) card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片 seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识 bot_uuid: str = None # 机器人UUID app_ticket: str = None # 商店应用用到 app_access_token: str = None # 商店应用用到 app_access_token_expire_at: int = None tenant_access_tokens: dict[str, dict[str, str]] = {} # 租户access_token映射 def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): quart_app = quart.Quart(__name__) async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1): lb_event = await self.event_converter.target2yiri(event, self.api_client) await self.listeners[type(lb_event)](lb_event, self) def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1): asyncio.create_task(on_message(event)) event_handler = ( lark_oapi.EventDispatcherHandler.builder('', '').register_p2_im_message_receive_v1(sync_on_message).build() ) bot_account_id = config['bot_name'] bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler) api_client = self.build_api_client(config) cipher = AESCipher(config.get('encrypt-key', '')) self.request_app_ticket(api_client, config) super().__init__( config=config, logger=logger, lark_tenant_key=config.get('lark_tenant_key', ''), card_id_dict={}, seq=1, listeners={}, quart_app=quart_app, bot=bot, api_client=api_client, bot_account_id=bot_account_id, cipher=cipher, **kwargs, ) def request_app_ticket(self, api_client, config): app_id = config['app_id'] app_secret = config['app_secret'] print(f'Requesting app ticket for app_id: {app_id[:3]}***{app_id[-3:]}') if 'isv' == config.get('app_type', 'self'): request: ResendAppTicketRequest = ( ResendAppTicketRequest.builder() .request_body(ResendAppTicketRequestBody.builder().app_id(app_id).app_secret(app_secret).build()) .build() ) response: ResendAppTicketResponse = api_client.auth.v3.app_ticket.resend(request) if not response.success(): raise Exception( f'client.auth.v3.auth.app_ticket_resend failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) def request_app_access_token(self): app_id = self.config['app_id'] app_secret = self.config['app_secret'] if 'isv' == self.config.get('app_type', 'self'): request: CreateAppAccessTokenRequest = ( CreateAppAccessTokenRequest.builder() .request_body( CreateAppAccessTokenRequestBody.builder() .app_id(app_id) .app_secret(app_secret) .app_ticket(self.app_ticket) .build() ) .build() ) response: CreateAppAccessTokenResponse = self.api_client.auth.v3.app_access_token.create(request) if not response.success(): raise Exception( f'client.auth.v3.auth.app_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) content = json.loads(response.raw.content) self.app_access_token = content['app_access_token'] self.app_access_token_expire_at = int(time.time()) + content['expire'] - 300 def get_app_access_token(self): if 'isv' != self.config.get('app_type', 'self'): return None if ( self.app_access_token is None or self.app_access_token_expire_at is None or int(time.time()) >= self.app_access_token_expire_at ): self.request_app_access_token() return self.app_access_token def request_tenant_access_token(self, tenant_key: str): app_access_token = self.get_app_access_token() if 'isv' == self.config.get('app_type', 'self'): request: CreateTenantAccessTokenRequest = ( CreateTenantAccessTokenRequest.builder() .request_body( CreateTenantAccessTokenRequestBody.builder() .app_access_token(app_access_token) .tenant_key(tenant_key) .build() ) .build() ) response: CreateTenantAccessTokenResponse = self.api_client.auth.v3.tenant_access_token.create(request) if not response.success(): raise Exception( f'client.auth.v3.auth.tenant_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) content = json.loads(response.raw.content) tenant_access_token = content['tenant_access_token'] expire = content['expire'] self.tenant_access_tokens[tenant_key] = { 'token': tenant_access_token, 'expire_at': int(time.time()) + expire - 300, } def get_tenant_access_token(self, tenant_key: str): if tenant_key is None or 'isv' != self.config.get('app_type', 'self'): return None tenant_access_token = self.tenant_access_tokens.get(tenant_key) if tenant_access_token is None or int(time.time()) >= tenant_access_token['expire_at']: self.request_tenant_access_token(tenant_key) return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None: """ Get topic-scoped launcher_id for thread-aware session isolation. For group thread messages, returns "{group_id}_{thread_id}" to ensure conversation context stays stable per topic. Returns None for non-thread messages or P2P messages. """ source_event = getattr(event.source_platform_object, 'event', None) if not source_event: return None message = getattr(source_event, 'message', None) if not message: return None thread_id = getattr(message, 'thread_id', None) if not thread_id: return None if isinstance(event, platform_events.GroupMessage): return f'{event.group.id}_{thread_id}' return None def build_api_client(self, config): app_id = config['app_id'] app_secret = config['app_secret'] api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build() if 'isv' == config.get('app_type', 'self'): api_client = ( lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build() ) return api_client async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): pass async def is_stream_output_supported(self) -> bool: is_stream = False if self.config.get('enable-stream-reply', None): is_stream = True return is_stream async def create_card_id(self, message_id): try: # self.logger.debug('飞书支持stream输出,创建卡片......') card_data = { 'schema': '2.0', 'config': { 'update_multi': True, 'streaming_mode': True, 'streaming_config': { 'print_step': {'default': 1}, 'print_frequency_ms': {'default': 70}, 'print_strategy': 'fast', }, }, 'body': { 'direction': 'vertical', 'padding': '12px 12px 12px 12px', 'elements': [ { 'tag': 'div', 'text': { 'tag': 'plain_text', 'content': 'LangBot', 'text_size': 'normal', 'text_align': 'left', 'text_color': 'default', }, 'icon': { 'tag': 'custom_icon', 'img_key': 'img_v3_02p3_05c65d5d-9bad-440a-a2fb-c89571bfd5bg', }, }, { 'tag': 'markdown', 'content': '', 'text_align': 'left', 'text_size': 'normal', 'margin': '0px 0px 0px 0px', 'element_id': 'streaming_txt', }, { 'tag': 'markdown', 'content': '', 'text_align': 'left', 'text_size': 'normal', 'margin': '0px 0px 0px 0px', }, { 'tag': 'column_set', 'horizontal_spacing': '8px', 'horizontal_align': 'left', 'columns': [ { 'tag': 'column', 'width': 'weighted', 'elements': [ { 'tag': 'markdown', 'content': '', 'text_align': 'left', 'text_size': 'normal', 'margin': '0px 0px 0px 0px', }, { 'tag': 'markdown', 'content': '', 'text_align': 'left', 'text_size': 'normal', 'margin': '0px 0px 0px 0px', }, { 'tag': 'markdown', 'content': '', 'text_align': 'left', 'text_size': 'normal', 'margin': '0px 0px 0px 0px', }, ], 'padding': '0px 0px 0px 0px', 'direction': 'vertical', 'horizontal_spacing': '8px', 'vertical_spacing': '2px', 'horizontal_align': 'left', 'vertical_align': 'top', 'margin': '0px 0px 0px 0px', 'weight': 1, } ], 'margin': '0px 0px 0px 0px', }, {'tag': 'hr', 'margin': '0px 0px 0px 0px'}, { 'tag': 'column_set', 'horizontal_spacing': '12px', 'horizontal_align': 'right', 'columns': [ { 'tag': 'column', 'width': 'weighted', 'elements': [ { 'tag': 'markdown', 'content': '以上内容由 AI 生成,仅供参考。更多详细、准确信息可点击引用链接查看', 'text_align': 'left', 'text_size': 'notation', 'margin': '4px 0px 0px 0px', 'icon': { 'tag': 'standard_icon', 'token': 'robot_outlined', 'color': 'grey', }, } ], 'padding': '0px 0px 0px 0px', 'direction': 'vertical', 'horizontal_spacing': '8px', 'vertical_spacing': '8px', 'horizontal_align': 'left', 'vertical_align': 'top', 'margin': '0px 0px 0px 0px', 'weight': 1, }, { 'tag': 'column', 'width': '20px', 'elements': [ { 'tag': 'button', 'text': {'tag': 'plain_text', 'content': ''}, 'type': 'text', 'width': 'fill', 'size': 'medium', 'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'}, 'hover_tips': {'tag': 'plain_text', 'content': '有帮助'}, 'margin': '0px 0px 0px 0px', } ], 'padding': '0px 0px 0px 0px', 'direction': 'vertical', 'horizontal_spacing': '8px', 'vertical_spacing': '8px', 'horizontal_align': 'left', 'vertical_align': 'top', 'margin': '0px 0px 0px 0px', }, { 'tag': 'column', 'width': '30px', 'elements': [ { 'tag': 'button', 'text': {'tag': 'plain_text', 'content': ''}, 'type': 'text', 'width': 'default', 'size': 'medium', 'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'}, 'hover_tips': {'tag': 'plain_text', 'content': '无帮助'}, 'margin': '0px 0px 0px 0px', } ], 'padding': '0px 0px 0px 0px', 'vertical_spacing': '8px', 'horizontal_align': 'left', 'vertical_align': 'top', 'margin': '0px 0px 0px 0px', }, ], 'margin': '0px 0px 4px 0px', }, ], }, } # delay / fast 创建卡片模板,delay 延迟打印,fast 实时打印,可以自定义更好看的消息模板 request: CreateCardRequest = ( CreateCardRequest.builder() .request_body(CreateCardRequestBody.builder().type('card_json').data(json.dumps(card_data)).build()) .build() ) # 发起请求 response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request) # 处理失败返回 if not response.success(): raise Exception( f'client.cardkit.v1.card.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) self.card_id_dict[message_id] = response.data.card_id card_id = response.data.card_id return card_id except Exception as e: raise e async def create_message_card(self, message_id, event) -> str: """ 创建卡片消息。 使用卡片消息是因为普通消息更新次数有限制,而大模型流式返回结果可能很多而超过限制,而飞书卡片没有这个限制(api免费次数有限) """ # message_id = event.message_chain.message_id card_id = await self.create_card_id(message_id) content = { 'type': 'card', 'data': {'card_id': card_id, 'template_variable': {'content': 'Thinking...'}}, } # 当收到消息时发送消息模板,可添加模板变量,详情查看飞书中接口文档 request: ReplyMessageRequest = ( ReplyMessageRequest.builder() .message_id(event.message_chain.message_id) .request_body( ReplyMessageRequestBody.builder().content(json.dumps(content)).msg_type('interactive').build() ) .build() ) tenant_key = event.source_platform_object.header.tenant_key if event.source_platform_object else None app_access_token = self.get_app_access_token() tenant_access_token = self.get_tenant_access_token(tenant_key) req_opt: RequestOption = ( RequestOption.builder() .app_ticket(self.app_ticket) .tenant_key(tenant_key) .app_access_token(app_access_token) .tenant_access_token(tenant_access_token) .build() ) # 发起请求 response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt) # 处理失败返回 if not response.success(): raise Exception( f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) return True async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): # 不再需要了,因为message_id已经被包含到message_chain中 # lark_event = await self.event_converter.yiri2target(message_source) text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) # Send text message if there are text elements if text_elements: # Determine msg_type based on content: use 'post' if at mentions # are present (requires post paragraph structure), otherwise 'text' needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph) if needs_post: msg_type = 'post' final_content = json.dumps( { 'zh_Hans': { 'title': '', 'content': text_elements, }, } ) else: msg_type = 'text' parts = [] for paragraph in text_elements: para_text = ''.join(ele.get('text', '') for ele in paragraph) if para_text: parts.append(para_text) final_content = json.dumps({'text': '\n\n'.join(parts)}) request: ReplyMessageRequest = ( ReplyMessageRequest.builder() .message_id(message_source.message_chain.message_id) .request_body( ReplyMessageRequestBody.builder() .content(final_content) .msg_type(msg_type) .reply_in_thread(False) .uuid(str(uuid.uuid4())) .build() ) .build() ) tenant_key = ( message_source.source_platform_object.header.tenant_key if message_source.source_platform_object else None ) app_access_token = self.get_app_access_token() tenant_access_token = self.get_tenant_access_token(tenant_key) req_opt: RequestOption = ( RequestOption.builder() .app_ticket(self.app_ticket) .tenant_key(tenant_key) .app_access_token(app_access_token) .tenant_access_token(tenant_access_token) .build() ) response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt) if not response.success(): raise Exception( f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) # Send media messages separately (image, audio, file, etc.) for media in media_items: request: ReplyMessageRequest = ( ReplyMessageRequest.builder() .message_id(message_source.message_chain.message_id) .request_body( ReplyMessageRequestBody.builder() .content(json.dumps(media['content'])) .msg_type(media['msg_type']) .reply_in_thread(False) .uuid(str(uuid.uuid4())) .build() ) .build() ) tenant_key = ( message_source.source_platform_object.header.tenant_key if message_source.source_platform_object else None ) app_access_token = self.get_app_access_token() tenant_access_token = self.get_tenant_access_token(tenant_key) req_opt: RequestOption = ( RequestOption.builder() .app_ticket(self.app_ticket) .tenant_key(tenant_key) .app_access_token(app_access_token) .tenant_access_token(tenant_access_token) .build() ) response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt) if not response.success(): raise Exception( f'client.im.v1.message.reply ({media["msg_type"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) async def reply_message_chunk( self, message_source: platform_events.MessageEvent, bot_message, message: platform_message.MessageChain, quote_origin: bool = False, is_final: bool = False, ): """ 回复消息变成更新卡片消息 """ # self.seq += 1 message_id = bot_message.resp_message_id msg_seq = bot_message.msg_sequence if msg_seq % 8 == 0 or is_final: text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) text_message = '' if text_elements: parts = [] for paragraph in text_elements: para_text = ''.join(ele['text'] for ele in paragraph if ele['tag'] in ('text', 'md')) if para_text: parts.append(para_text) text_message = '\n\n'.join(parts) # content = { # 'type': 'card_json', # 'data': {'card_id': self.card_id_dict[message_id], 'elements': {'content': text_message}}, # } request: ContentCardElementRequest = ( ContentCardElementRequest.builder() .card_id(self.card_id_dict[message_id]) .element_id('streaming_txt') .request_body( ContentCardElementRequestBody.builder() # .uuid("a0d69e20-1dd1-458b-k525-dfeca4015204") .content(text_message) .sequence(msg_seq) .build() ) .build() ) if is_final and bot_message.tool_calls is None: # self.seq = 1 # 消息回复结束之后重置seq self.card_id_dict.pop(message_id) # 清理已经使用过的卡片 tenant_key = ( message_source.source_platform_object.header.tenant_key if message_source.source_platform_object else None ) app_access_token = self.get_app_access_token() tenant_access_token = self.get_tenant_access_token(tenant_key) req_opt: RequestOption = ( RequestOption.builder() .app_ticket(self.app_ticket) .tenant_key(tenant_key) .app_access_token(app_access_token) .tenant_access_token(tenant_access_token) .build() ) # 发起请求 response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request, req_opt) # 处理失败返回 if not response.success(): raise Exception( f'client.im.v1.message.patch failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) return # Send media messages when streaming is done if is_final and media_items: for media in media_items: media_request: ReplyMessageRequest = ( ReplyMessageRequest.builder() .message_id(message_source.message_chain.message_id) .request_body( ReplyMessageRequestBody.builder() .content(json.dumps(media['content'])) .msg_type(media['msg_type']) .reply_in_thread(False) .uuid(str(uuid.uuid4())) .build() ) .build() ) media_response: ReplyMessageResponse = await self.api_client.im.v1.message.areply( media_request, req_opt ) if not media_response.success(): raise Exception( f'client.im.v1.message.reply ({media["msg_type"]}) failed, code: {media_response.code}, msg: {media_response.msg}, log_id: {media_response.get_log_id()}' ) async def is_muted(self, group_id: int) -> bool: return False def register_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): self.listeners[event_type] = callback def unregister_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): self.listeners.pop(event_type) def set_bot_uuid(self, bot_uuid: str): """设置 bot UUID(用于生成 webhook URL)""" self.bot_uuid = bot_uuid def get_event_type(self, data): schema = '1.0' if 'schema' in data: schema = data['schema'] if '2.0' == schema: return data['header']['event_type'] elif 'event' in data: return data['event']['type'] else: return data['type'] async def handle_unified_webhook(self, bot_uuid: str, path: str, request): """处理统一 webhook 请求。 Args: bot_uuid: Bot 的 UUID path: 子路径(如果有的话) request: Quart Request 对象 Returns: 响应数据 """ try: data = await request.json if 'encrypt' in data: data = self.cipher.decrypt_string(data['encrypt']) data = json.loads(data) type = self.get_event_type(data) context = EventContext(data) if 'url_verification' == type: # todo 验证verification token return {'challenge': data.get('challenge')} elif 'app_ticket' == type: self.app_ticket = context.event['app_ticket'] elif 'im.message.receive_v1' == type: try: p2v1 = P2ImMessageReceiveV1() p2v1.header = context.header event = P2ImMessageReceiveV1Data() event.message = EventMessage(context.event['message']) event.sender = EventSender(context.event['sender']) p2v1.event = event p2v1.schema = context.schema event = await self.event_converter.target2yiri(p2v1, self.api_client) except Exception: await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') if event.__class__ in self.listeners: await self.listeners[event.__class__](event, self) elif 'im.chat.member.bot.added_v1' == type: try: bot_added_welcome_msg = self.config.get('bot_added_welcome', '') if bot_added_welcome_msg: final_content = { 'zh_Hans': { 'title': '', 'content': [[{'tag': 'md', 'text': bot_added_welcome_msg}]], }, } chat_id = context.event['chat_id'] request: CreateMessageRequest = ( CreateMessageRequest.builder() .receive_id_type('chat_id') .request_body( CreateMessageRequestBody.builder() .receive_id(chat_id) .content(json.dumps(final_content)) .msg_type('post') .uuid(str(uuid.uuid4())) .build() ) .build() ) tenant_key = context.header.tenant_key if context.header else None app_access_token = self.get_app_access_token() tenant_access_token = self.get_tenant_access_token(tenant_key) req_opt: RequestOption = ( RequestOption.builder() .app_ticket(self.app_ticket) .tenant_key(tenant_key) .app_access_token(app_access_token) .tenant_access_token(tenant_access_token) .build() ) response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt) if not response.success(): raise Exception( f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) except Exception as e: print(f'im.chat.member.bot.added_v1: {e}') await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') return {'code': 200, 'message': 'ok'} except Exception as e: print(f'Error in lark callback: {e}') await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') return {'code': 500, 'message': 'error'} async def run_async(self): enable_webhook = self.config['enable-webhook'] if not enable_webhook: try: await self.bot._connect() except lark_oapi.ws.exception.ClientException as e: raise e except Exception as e: await self.bot._disconnect() if self.bot._auto_reconnect: await self.bot._reconnect() else: raise e else: # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 async def keep_alive(): while True: await asyncio.sleep(1) await keep_alive() async def kill(self) -> bool: # 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接 # 断开时lark.ws.Client的_receive_message_loop会打印error日志: receive message loop exit。然后进行重连, # 所以要设置_auto_reconnect=False,让其不重连。 self.bot._auto_reconnect = False await self.bot._disconnect() return False ================================================ FILE: src/langbot/pkg/platform/sources/lark.yaml ================================================ apiVersion: v1 kind: MessagePlatformAdapter metadata: name: lark label: en_US: Lark zh_Hans: 飞书 description: en_US: Lark Adapter zh_Hans: 飞书适配器,请查看文档了解使用方式 icon: lark.svg spec: config: - name: app_id label: en_US: App ID zh_Hans: 应用ID type: string required: true default: "" - name: app_secret label: en_US: App Secret zh_Hans: 应用密钥 type: string required: true default: "" - name: bot_name label: en_US: Bot Name zh_Hans: 机器人名称 description: en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息 type: string required: true default: "" - name: enable-webhook label: en_US: Enable Webhook Mode zh_Hans: 启用Webhook模式 description: en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式 type: boolean required: true default: false - name: encrypt-key label: en_US: Encrypt Key zh_Hans: 加密密钥 description: en_US: Only valid when webhook mode is enabled, please fill in the encrypt key zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥 type: string required: true default: "" - name: enable-stream-reply label: en_US: Enable Stream Reply Mode zh_Hans: 启用飞书流式回复模式 description: en_US: If enabled, the bot will use the stream of lark reply mode zh_Hans: 如果启用,将使用飞书流式方式来回复内容 type: boolean required: true default: false - name: app_type label: en_US: App Type zh_Hans: 应用类型 description: en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview type: select options: - name: self label: en_US: Self-built Application zh_Hans: 自建应用 - name: isv label: en_US: Store Application zh_Hans: 商店应用 required: false default: self - name: bot_added_welcome label: en_US: Bot Welcome Message zh_Hans: 机器人进群欢迎语 description: en_US: Welcome message when the bot is added to a group, supports Markdown format zh_Hans: 机器人进群欢迎语,支持 Markdown 格式 type: text required: false default: "" execution: python: path: ./lark.py attr: LarkAdapter ================================================ FILE: src/langbot/pkg/platform/sources/legacy/gewechat.py ================================================ import gewechat_client import typing import asyncio import traceback import time import re import copy import threading import quart from langbot.pkg.utils import httpclient import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter from ....core import app import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities from ....utils import image import xml.etree.ElementTree as ET from typing import Optional, Tuple from functools import partial from ...logger import EventLogger class GewechatMessageConverter(abstract_platform_adapter.AbstractMessageConverter): def __init__(self, config: dict): self.config = config @staticmethod async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]: content_list = [] for component in message_chain: if isinstance(component, platform_message.At): content_list.append({'type': 'at', 'target': component.target}) elif isinstance(component, platform_message.Plain): content_list.append({'type': 'text', 'content': component.text}) elif isinstance(component, platform_message.Image): if not component.url: pass content_list.append({'type': 'image', 'image': component.url}) elif isinstance(component, platform_message.Voice): content_list.append({'type': 'voice', 'url': component.url, 'length': component.length}) elif isinstance(component, platform_message.Forward): for node in component.node_list: content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain)) content_list.append({'type': 'image', 'image': component.url}) elif isinstance(component, platform_message.WeChatMiniPrograms): content_list.append( { 'type': 'WeChatMiniPrograms', 'mini_app_id': component.mini_app_id, 'display_name': component.display_name, 'page_path': component.page_path, 'cover_img_url': component.image_url, 'title': component.title, 'user_name': component.user_name, } ) elif isinstance(component, platform_message.WeChatForwardMiniPrograms): content_list.append( { 'type': 'WeChatForwardMiniPrograms', 'xml_data': component.xml_data, 'image_url': component.image_url, } ) elif isinstance(component, platform_message.WeChatEmoji): content_list.append( { 'type': 'WeChatEmoji', 'emoji_md5': component.emoji_md5, 'emoji_size': component.emoji_size, } ) elif isinstance(component, platform_message.WeChatLink): content_list.append( { 'type': 'WeChatLink', 'link_title': component.link_title, 'link_desc': component.link_desc, 'link_thumb_url': component.link_thumb_url, 'link_url': component.link_url, } ) elif isinstance(component, platform_message.WeChatForwardLink): content_list.append({'type': 'WeChatForwardLink', 'xml_data': component.xml_data}) elif isinstance(component, platform_message.Voice): content_list.append({'type': 'voice', 'url': component.url, 'length': component.length}) elif isinstance(component, platform_message.WeChatForwardImage): content_list.append({'type': 'WeChatForwardImage', 'xml_data': component.xml_data}) elif isinstance(component, platform_message.WeChatForwardFile): content_list.append({'type': 'WeChatForwardFile', 'xml_data': component.xml_data}) elif isinstance(component, platform_message.WeChatAppMsg): content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg}) # 引用消息转发 elif isinstance(component, platform_message.WeChatForwardQuote): content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg}) elif isinstance(component, platform_message.Forward): for node in component.node_list: if node.message_chain: content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain)) return content_list async def target2yiri(self, message: dict, bot_account_id: str) -> platform_message.MessageChain: """外部消息转平台消息""" # 数据预处理 message_list = [] ats_bot = False # 是否被@ content = message['Data']['Content']['string'] content_no_preifx = content # 群消息则去掉前缀 is_group_message = self._is_group_message(message) if is_group_message: ats_bot = self._ats_bot(message, bot_account_id) if '@所有人' in content: message_list.append(platform_message.AtAll()) elif ats_bot: message_list.append(platform_message.At(target=bot_account_id)) content_no_preifx, _ = self._extract_content_and_sender(content) msg_type = message['Data']['MsgType'] # 映射消息类型到处理器方法 handler_map = { 1: self._handler_text, 3: self._handler_image, 34: self._handler_voice, 49: self._handler_compound, # 复合类型 } # 分派处理 handler = handler_map.get(msg_type, self._handler_default) handler_result = await handler( message=message, # 原始的message content_no_preifx=content_no_preifx, # 处理后的content ) if handler_result and len(handler_result) > 0: message_list.extend(handler_result) return platform_message.MessageChain(message_list) async def _handler_text(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理文本消息 (msg_type=1)""" if message and self._is_group_message(message): pattern = r'@\S{1,20}' content_no_preifx = re.sub(pattern, '', content_no_preifx) return platform_message.MessageChain([platform_message.Plain(content_no_preifx)]) async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理图像消息 (msg_type=3)""" try: image_xml = content_no_preifx if not image_xml: return platform_message.MessageChain([platform_message.Unknown('[图片内容为空]')]) base64_str, image_format = await image.get_gewechat_image_base64( gewechat_url=self.config['gewechat_url'], gewechat_file_url=self.config['gewechat_file_url'], app_id=self.config['app_id'], xml_content=image_xml, token=self.config['token'], image_type=2, ) elements = [ platform_message.Image(base64=f'data:image/{image_format};base64,{base64_str}'), platform_message.WeChatForwardImage(xml_data=image_xml), # 微信消息转发 ] return platform_message.MessageChain(elements) except Exception as e: print(f'处理图片失败: {str(e)}') return platform_message.MessageChain([platform_message.Unknown('[图片处理失败]')]) async def _handler_voice(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理语音消息 (msg_type=34)""" message_List = [] try: # 从消息中提取语音数据(需根据实际数据结构调整字段名) audio_base64 = message['Data']['ImgBuf']['buffer'] # 验证语音数据有效性 if not audio_base64: message_List.append(platform_message.Unknown(text='[语音内容为空]')) return platform_message.MessageChain(message_List) # 转换为平台支持的语音格式(如 Silk 格式) voice_element = platform_message.Voice(base64=f'data:audio/silk;base64,{audio_base64}') message_List.append(voice_element) except KeyError as e: print(f'语音数据字段缺失: {str(e)}') message_List.append(platform_message.Unknown(text='[语音数据解析失败]')) except Exception as e: print(f'处理语音消息异常: {str(e)}') message_List.append(platform_message.Unknown(text='[语音处理失败]')) return platform_message.MessageChain(message_List) async def _handler_compound(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理复合消息 (msg_type=49),根据子类型分派""" try: xml_data = ET.fromstring(content_no_preifx) appmsg_data = xml_data.find('.//appmsg') if appmsg_data: data_type = appmsg_data.findtext('.//type', '') # 二次分派处理器 sub_handler_map = { '57': self._handler_compound_quote, '5': self._handler_compound_link, '6': self._handler_compound_file, '33': self._handler_compound_mini_program, '36': self._handler_compound_mini_program, '2000': partial(self._handler_compound_unsupported, text='[转账消息]'), '2001': partial(self._handler_compound_unsupported, text='[红包消息]'), '51': partial(self._handler_compound_unsupported, text='[视频号消息]'), } handler = sub_handler_map.get(data_type, self._handler_compound_unsupported) return await handler( message=message, # 原始msg xml_data=xml_data, # xml数据 ) else: return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)]) except Exception as e: print(f'解析复合消息失败: {str(e)}') return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)]) async def _handler_compound_quote( self, message: Optional[dict], xml_data: ET.Element ) -> platform_message.MessageChain: """处理引用消息 (data_type=57)""" message_list = [] # print("_handler_compound_quote", ET.tostring(xml_data, encoding='unicode')) appmsg_data = xml_data.find('.//appmsg') quote_data = '' # 引用原文 user_data = '' # 用户消息 sender_id = xml_data.findtext('.//fromusername') # 发送方:单聊用户/群member if appmsg_data: user_data = appmsg_data.findtext('.//title') or '' quote_data = appmsg_data.find('.//refermsg').findtext('.//content') message_list.append( platform_message.WeChatForwardQuote(app_msg=ET.tostring(appmsg_data, encoding='unicode')) ) # quote_data原始的消息 if quote_data: quote_data_message_list = platform_message.MessageChain() # 文本消息 try: if '' not in quote_data: quote_data_message_list.append(platform_message.Plain(quote_data)) else: # 引用消息展开 quote_data_xml = ET.fromstring(quote_data) if quote_data_xml.find('img'): quote_data_message_list.extend(await self._handler_image(None, quote_data)) elif quote_data_xml.find('voicemsg'): quote_data_message_list.extend(await self._handler_voice(None, quote_data)) elif quote_data_xml.find('videomsg'): quote_data_message_list.extend(await self._handler_default(None, quote_data)) # 先不处理 else: # appmsg quote_data_message_list.extend(await self._handler_compound(None, quote_data)) except Exception as e: print(f'处理引用消息异常 expcetion:{e}') quote_data_message_list.append(platform_message.Plain(quote_data)) message_list.append( platform_message.Quote( sender_id=sender_id, origin=quote_data_message_list, ) ) if len(user_data) > 0: pattern = r'@\S{1,20}' user_data = re.sub(pattern, '', user_data) message_list.append(platform_message.Plain(user_data)) # for comp in message_list: # if isinstance(comp, platform_message.Quote): # print(f"quote_message_chain len={len(message_list)}") # print(f"quote_message_chain send_id={comp.sender_id}" ) # for quote_item in comp.origin: # print(f"--quote_message_component [msg_type={quote_item.type}][message={quote_item}]" ) # else: # print(f"quote_message_chain plain [msg_type={comp.type}][message={comp.text}]") return platform_message.MessageChain(message_list) async def _handler_compound_file(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain: """处理文件消息 (data_type=6)""" xml_data_str = ET.tostring(xml_data, encoding='unicode') return platform_message.MessageChain([platform_message.WeChatForwardFile(xml_data=xml_data_str)]) async def _handler_compound_link(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain: """处理链接消息(如公众号文章、外部网页)""" message_list = [] try: # 解析 XML 中的链接参数 appmsg = xml_data.find('.//appmsg') if appmsg is None: return platform_message.MessageChain() message_list.append( platform_message.WeChatLink( link_title=appmsg.findtext('title', ''), link_desc=appmsg.findtext('des', ''), link_url=appmsg.findtext('url', ''), link_thumb_url=appmsg.findtext('thumburl', ''), # 这个字段拿不到 ) ) # 转发消息 xml_data_str = ET.tostring(xml_data, encoding='unicode') # print(xml_data_str) message_list.append(platform_message.WeChatForwardLink(xml_data=xml_data_str)) except Exception as e: print(f'解析链接消息失败: {str(e)}') return platform_message.MessageChain(message_list) async def _handler_compound_mini_program( self, message: dict, xml_data: ET.Element ) -> platform_message.MessageChain: """处理小程序消息(如小程序卡片、服务通知)""" xml_data_str = ET.tostring(xml_data, encoding='unicode') return platform_message.MessageChain([platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str)]) async def _handler_default(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: """处理未知消息类型""" if message: msg_type = message['Data']['MsgType'] else: msg_type = '' return platform_message.MessageChain([platform_message.Unknown(text=f'[未知消息类型 msg_type:{msg_type}]')]) def _handler_compound_unsupported( self, message: dict, xml_data: str, text: Optional[str] = None ) -> platform_message.MessageChain: """处理未支持复合消息类型(msg_type=49)子类型""" if not text: text = f'[xml_data={xml_data}]' content_list = [] content_list.append(platform_message.Unknown(text=f'[处理未支持复合消息类型[msg_type=49]|{text}')) return platform_message.MessageChain(content_list) # 返回是否被艾特 def _ats_bot(self, message: dict, bot_account_id: str) -> bool: ats_bot = False try: to_user_name = message['Wxid'] # 接收方: 所属微信的wxid raw_content = message['Data']['Content']['string'] # 原始消息内容 content_no_prefix, _ = self._extract_content_and_sender(raw_content) # 直接艾特机器人(这个有bug,当被引用的消息里面有@bot,会套娃 # ats_bot = ats_bot or (f"@{bot_account_id}" in content_no_prefix) # 文本类@bot push_content = message.get('Data', {}).get('PushContent', '') ats_bot = ats_bot or ('在群聊中@了你' in push_content) # 引用别人时@bot msg_source = message.get('Data', {}).get('MsgSource', '') or '' if len(msg_source) > 0: msg_source_data = ET.fromstring(msg_source) at_user_list = msg_source_data.findtext('atuserlist') or '' ats_bot = ats_bot or (to_user_name in at_user_list) # 引用bot if message.get('Data', {}).get('MsgType', 0) == 49: xml_data = ET.fromstring(content_no_prefix) appmsg_data = xml_data.find('.//appmsg') tousername = message['Wxid'] if appmsg_data: # 接收方: 所属微信的wxid quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr') # 引用消息的原发送者 ats_bot = ats_bot or (quote_id == tousername) except Exception as e: print(f'Error in gewechat _ats_bot: {e}') finally: return ats_bot # 提取一下content前面的sender_id, 和去掉前缀的内容 def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]: try: # 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉 # add: 有些用户的wxid不是上述格式。换成user_name: regex = re.compile(r'^[a-zA-Z0-9_\-]{5,20}:') line_split = raw_content.split('\n') if len(line_split) > 0 and regex.match(line_split[0]): raw_content = '\n'.join(line_split[1:]) sender_id = line_split[0].strip(':') return raw_content, sender_id except Exception as e: print(f'_extract_content_and_sender got except: {e}') finally: return raw_content, None # 是否是群消息 def _is_group_message(self, message: dict) -> bool: from_user_name = message['Data']['FromUserName']['string'] return from_user_name.endswith('@chatroom') class GewechatEventConverter(abstract_platform_adapter.AbstractEventConverter): def __init__(self, config: dict): self.config = config self.message_converter = GewechatMessageConverter(config) @staticmethod async def yiri2target(event: platform_events.MessageEvent) -> dict: pass async def target2yiri(self, event: dict, bot_account_id: str) -> platform_events.MessageEvent: # print(event) # 排除自己发消息回调回答问题 if event['Wxid'] == event['Data']['FromUserName']['string']: return None # 排除公众号以及微信团队消息 if event['Data']['FromUserName']['string'].startswith('gh_') or event['Data']['FromUserName'][ 'string' ].startswith('weixin'): return None message_chain = await self.message_converter.target2yiri(copy.deepcopy(event), bot_account_id) if not message_chain: return None if '@chatroom' in event['Data']['FromUserName']['string']: # 找出开头的 wxid_ 字符串,以:结尾 sender_wxid = event['Data']['Content']['string'].split(':')[0] return platform_events.GroupMessage( sender=platform_entities.GroupMember( id=sender_wxid, member_name=event['Data']['FromUserName']['string'], permission=platform_entities.Permission.Member, group=platform_entities.Group( id=event['Data']['FromUserName']['string'], name=event['Data']['FromUserName']['string'], permission=platform_entities.Permission.Member, ), special_title='', ), message_chain=message_chain, time=event['Data']['CreateTime'], source_platform_object=event, ) else: return platform_events.FriendMessage( sender=platform_entities.Friend( id=event['Data']['FromUserName']['string'], nickname=event['Data']['FromUserName']['string'], remark='', ), message_chain=message_chain, time=event['Data']['CreateTime'], source_platform_object=event, ) class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): name: str = 'gewechat' # 定义适配器名称 bot: gewechat_client.GewechatClient quart_app: quart.Quart bot_account_id: str config: dict ap: app.Application message_converter: GewechatMessageConverter event_converter: GewechatEventConverter listeners: typing.Dict[ typing.Type[platform_events.Event], typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], ] = {} def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config self.ap = ap self.logger = logger self.quart_app = quart.Quart(__name__) self.message_converter = GewechatMessageConverter(config) self.event_converter = GewechatEventConverter(config) @self.quart_app.route('/gewechat/callback', methods=['POST']) async def gewechat_callback(): data = await quart.request.json # print(json.dumps(data, indent=4, ensure_ascii=False)) await self.logger.debug(f'Gewechat callback event: {data}') if 'data' in data: data['Data'] = data['data'] if 'type_name' in data: data['TypeName'] = data['type_name'] # print(json.dumps(data, indent=4, ensure_ascii=False)) if 'testMsg' in data: return 'ok' elif 'TypeName' in data and data['TypeName'] == 'AddMsg': try: event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id) except Exception: await self.logger.error(f'Error in gewechat callback: {traceback.format_exc()}') if event.__class__ in self.listeners: await self.listeners[event.__class__](event, self) return 'ok' async def _handle_message(self, message: platform_message.MessageChain, target_id: str): """统一消息处理核心逻辑""" content_list = await self.message_converter.yiri2target(message) at_targets = [item['target'] for item in content_list if item['type'] == 'at'] # 处理@逻辑 at_targets = at_targets or [] member_info = [] if at_targets: member_info = self.bot.get_chatroom_member_detail(self.config['app_id'], target_id, at_targets[::-1])[ 'data' ] # 处理消息组件 for msg in content_list: # 文本消息处理@ if msg['type'] == 'text' and at_targets: for member in member_info: msg['content'] = f'@{member["nickName"]} {msg["content"]}' # 统一消息派发 handler_map = { 'text': lambda msg: self.bot.post_text( app_id=self.config['app_id'], to_wxid=target_id, content=msg['content'], ats=','.join(at_targets), ), 'image': lambda msg: self.bot.post_image( app_id=self.config['app_id'], to_wxid=target_id, img_url=msg['image'], ), 'WeChatForwardMiniPrograms': lambda msg: self.bot.forward_mini_app( app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'], cover_img_url=msg.get('image_url'), ), 'WeChatEmoji': lambda msg: self.bot.post_emoji( app_id=self.config['app_id'], to_wxid=target_id, emoji_md5=msg['emoji_md5'], emoji_size=msg['emoji_size'], ), 'WeChatLink': lambda msg: self.bot.post_link( app_id=self.config['app_id'], to_wxid=target_id, title=msg['link_title'], desc=msg['link_desc'], link_url=msg['link_url'], thumb_url=msg['link_thumb_url'], ), 'WeChatMiniPrograms': lambda msg: self.bot.post_mini_app( app_id=self.config['app_id'], to_wxid=target_id, mini_app_id=msg['mini_app_id'], display_name=msg['display_name'], page_path=msg['page_path'], cover_img_url=msg['cover_img_url'], title=msg['title'], user_name=msg['user_name'], ), 'WeChatForwardLink': lambda msg: self.bot.forward_url( app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] ), 'WeChatForwardImage': lambda msg: self.bot.forward_image( app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] ), 'WeChatForwardFile': lambda msg: self.bot.forward_file( app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] ), 'voice': lambda msg: self.bot.post_voice( app_id=self.config['app_id'], to_wxid=target_id, voice_url=msg['url'], voice_duration=msg['length'], ), 'WeChatAppMsg': lambda msg: self.bot.post_app_msg( app_id=self.config['app_id'], to_wxid=target_id, appmsg=msg['app_msg'], ), 'at': lambda msg: None, } if handler := handler_map.get(msg['type']): handler(msg) else: await self.logger.warning(f'未处理的消息类型: {msg["type"]}') continue async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): """主动发送消息""" return await self._handle_message(message, target_id) async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): """回复消息""" if message_source.source_platform_object: target_id = message_source.source_platform_object['Data']['FromUserName']['string'] return await self._handle_message(message, target_id) async def is_muted(self, group_id: int) -> bool: pass def register_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): self.listeners[event_type] = callback def unregister_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): pass async def run_async(self): if not self.config['token']: session = httpclient.get_session() async with session.post( f'{self.config["gewechat_url"]}/v2/api/tools/getTokenId', json={'app_id': self.config['app_id']}, ) as response: if response.status != 200: raise Exception(f'获取gewechat token失败: {await response.text()}') self.config['token'] = (await response.json())['data'] self.bot = gewechat_client.GewechatClient(f'{self.config["gewechat_url"]}/v2/api', self.config['token']) def gewechat_login_process(): app_id, error_msg = self.bot.login(self.config['app_id']) if error_msg: raise Exception(f'Gewechat 登录失败: {error_msg}') self.config['app_id'] = app_id print(f'Gewechat 登录成功,app_id: {app_id}') # 获取 nickname profile = self.bot.get_profile(self.config['app_id']) self.bot_account_id = profile['data']['nickName'] time.sleep(2) try: # gewechat-server容器重启, token会变,但是还会登录成功 # 换新token也会收不到回调,要重新登陆下。 self.bot.set_callback(self.config['token'], self.config['callback_url']) except Exception as e: raise Exception(f'设置 Gewechat 回调失败, token失效: {e}') threading.Thread(target=gewechat_login_process).start() async def shutdown_trigger_placeholder(): while True: await asyncio.sleep(1) await self.quart_app.run_task( host='0.0.0.0', port=self.config['port'], shutdown_trigger=shutdown_trigger_placeholder, ) async def kill(self) -> bool: pass ================================================ FILE: src/langbot/pkg/platform/sources/legacy/gewechat.yaml ================================================ apiVersion: v1 kind: MessagePlatformAdapter metadata: name: gewechat label: en_US: GeWeChat zh_Hans: GeWeChat(个人微信) description: en_US: GeWeChat Adapter zh_Hans: GeWeChat 适配器,请查看文档了解使用方式 icon: gewechat.png spec: config: - name: gewechat_url label: en_US: GeWeChat URL zh_Hans: GeWeChat URL type: string required: true default: "" - name: gewechat_file_url label: en_US: GeWeChat file download URL zh_Hans: GeWeChat 文件下载URL type: string required: true default: "" - name: port label: en_US: Port zh_Hans: 端口 type: integer required: true default: 2286 - name: callback_url label: en_US: Callback URL zh_Hans: 回调URL type: string required: true default: "" - name: app_id label: en_US: App ID zh_Hans: 应用ID type: string required: true default: "" - name: token label: en_US: Token zh_Hans: 令牌 type: string required: true default: "" execution: python: path: ./gewechat.py attr: GeWeChatAdapter ================================================ FILE: src/langbot/pkg/platform/sources/legacy/nakuru.py ================================================ # 加了之后会导致:https://github.com/Lxns-Network/nakuru-project/issues/25 # from __future__ import annotations import asyncio import typing import traceback import nakuru import nakuru.entities.components as nkc from ....pipeline.longtext.strategies import forward import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter from ...logger import EventLogger class NakuruProjectMessageConverter(abstract_platform_adapter.AbstractMessageConverter): """消息转换器""" @staticmethod def yiri2target(message_chain: platform_message.MessageChain) -> list: msg_list = [] if type(message_chain) is platform_message.MessageChain: msg_list = message_chain.__root__ elif type(message_chain) is list: msg_list = message_chain elif type(message_chain) is str: msg_list = [platform_message.Plain(message_chain)] else: raise Exception('Unknown message type: ' + str(message_chain) + str(type(message_chain))) nakuru_msg_list = [] # 遍历并转换 for component in msg_list: if type(component) is platform_message.Plain: nakuru_msg_list.append(nkc.Plain(component.text, False)) elif type(component) is platform_message.Image: if component.url is not None: nakuru_msg_list.append(nkc.Image.fromURL(component.url)) elif component.base64 is not None: nakuru_msg_list.append(nkc.Image.fromBase64(component.base64)) elif component.path is not None: nakuru_msg_list.append(nkc.Image.fromFileSystem(component.path)) elif type(component) is platform_message.At: nakuru_msg_list.append(nkc.At(qq=component.target)) elif type(component) is platform_message.AtAll: nakuru_msg_list.append(nkc.AtAll()) elif type(component) is platform_message.Voice: if component.url is not None: nakuru_msg_list.append(nkc.Record.fromURL(component.url)) elif component.path is not None: nakuru_msg_list.append(nkc.Record.fromFileSystem(component.path)) elif type(component) is forward.Forward: # 转发消息 yiri_forward_node_list = component.node_list nakuru_forward_node_list = [] # 遍历并转换 for yiri_forward_node in yiri_forward_node_list: try: content_list = NakuruProjectMessageConverter.yiri2target(yiri_forward_node.message_chain) nakuru_forward_node = nkc.Node( name=yiri_forward_node.sender_name, uin=yiri_forward_node.sender_id, time=int(yiri_forward_node.time.timestamp()) if yiri_forward_node.time is not None else None, content=content_list, ) nakuru_forward_node_list.append(nakuru_forward_node) except Exception: import traceback traceback.print_exc() nakuru_msg_list.append(nakuru_forward_node_list) else: nakuru_msg_list.append(nkc.Plain(str(component))) return nakuru_msg_list @staticmethod def target2yiri(message_chain: typing.Any, message_id: int = -1) -> platform_message.MessageChain: """将Yiri的消息链转换为YiriMirai的消息链""" assert type(message_chain) is list yiri_msg_list = [] import datetime # 添加Source组件以标记message_id等信息 yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now())) for component in message_chain: if type(component) is nkc.Plain: yiri_msg_list.append(platform_message.Plain(text=component.text)) elif type(component) is nkc.Image: yiri_msg_list.append(platform_message.Image(url=component.url)) elif type(component) is nkc.At: yiri_msg_list.append(platform_message.At(target=component.qq)) elif type(component) is nkc.AtAll: yiri_msg_list.append(platform_message.AtAll()) else: pass # logging.debug("转换后的消息链: " + str(yiri_msg_list)) chain = platform_message.MessageChain(yiri_msg_list) return chain class NakuruProjectEventConverter(abstract_platform_adapter.AbstractEventConverter): """事件转换器""" @staticmethod def yiri2target(event: typing.Type[platform_events.Event]): if event is platform_events.GroupMessage: return nakuru.GroupMessage elif event is platform_events.FriendMessage: return nakuru.FriendMessage else: raise Exception('未支持转换的事件类型: ' + str(event)) @staticmethod def target2yiri(event: typing.Any) -> platform_events.Event: yiri_chain = NakuruProjectMessageConverter.target2yiri(event.message, event.message_id) if type(event) is nakuru.FriendMessage: # 私聊消息事件 return platform_events.FriendMessage( sender=platform_entities.Friend( id=event.sender.user_id, nickname=event.sender.nickname, remark=event.sender.nickname, ), message_chain=yiri_chain, time=event.time, ) elif type(event) is nakuru.GroupMessage: # 群聊消息事件 permission = 'MEMBER' if event.sender.role == 'admin': permission = 'ADMINISTRATOR' elif event.sender.role == 'owner': permission = 'OWNER' return platform_events.GroupMessage( sender=platform_entities.GroupMember( id=event.sender.user_id, member_name=event.sender.nickname, permission=permission, group=platform_entities.Group( id=event.group_id, name=event.sender.nickname, permission=platform_entities.Permission.Member, ), special_title=event.sender.title, ), message_chain=yiri_chain, time=event.time, ) else: raise Exception('未支持转换的事件类型: ' + str(event)) class NakuruAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): """nakuru-project适配器""" bot: nakuru.CQHTTP bot_account_id: int message_converter: NakuruProjectMessageConverter = NakuruProjectMessageConverter() event_converter: NakuruProjectEventConverter = NakuruProjectEventConverter() listener_list: list[dict] # ap: app.Application cfg: dict def __init__(self, cfg: dict, ap, logger: EventLogger): """初始化nakuru-project的对象""" cfg['port'] = cfg['ws_port'] del cfg['ws_port'] self.cfg = cfg self.ap = ap self.logger = logger self.listener_list = [] self.bot = nakuru.CQHTTP(**self.cfg) async def send_message( self, target_type: str, target_id: str, message: typing.Union[platform_message.MessageChain, list], converted: bool = False, ): task = None converted_msg = self.message_converter.yiri2target(message) if not converted else message # 检查是否有转发消息 has_forward = False for msg in converted_msg: if type(msg) is list: # 转发消息,仅回复此消息组件 has_forward = True converted_msg = msg break if has_forward: if target_type == 'group': task = self.bot.sendGroupForwardMessage(int(target_id), converted_msg) elif target_type == 'person': task = self.bot.sendPrivateForwardMessage(int(target_id), converted_msg) else: raise Exception('Unknown target type: ' + target_type) else: if target_type == 'group': task = self.bot.sendGroupMessage(int(target_id), converted_msg) elif target_type == 'person': task = self.bot.sendFriendMessage(int(target_id), converted_msg) else: raise Exception('Unknown target type: ' + target_type) await task async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): message = self.message_converter.yiri2target(message) if quote_origin: # 在前方添加引用组件 message.insert( 0, nkc.Reply( id=message_source.message_chain.message_id, ), ) if type(message_source) is platform_events.GroupMessage: await self.send_message('group', message_source.sender.group.id, message, converted=True) elif type(message_source) is platform_events.FriendMessage: await self.send_message('person', message_source.sender.id, message, converted=True) else: raise Exception('Unknown message source type: ' + str(type(message_source))) def is_muted(self, group_id: int) -> bool: import time # 检查是否被禁言 group_member_info = asyncio.run(self.bot.getGroupMemberInfo(group_id, self.bot_account_id)) return group_member_info.shut_up_timestamp > int(time.time()) def register_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): try: source_cls = NakuruProjectEventConverter.yiri2target(event_type) # 包装函数 async def listener_wrapper(app: nakuru.CQHTTP, source: source_cls): # type: ignore await callback(self.event_converter.target2yiri(source), self) # 将包装函数和原函数的对应关系存入列表 self.listener_list.append( { 'event_type': event_type, 'callable': callback, 'wrapper': listener_wrapper, } ) # 注册监听器 self.bot.receiver(source_cls.__name__)(listener_wrapper) except Exception as e: self.logger.error(f'Error in nakuru register_listener: {traceback.format_exc()}') raise e def unregister_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): nakuru_event_name = self.event_converter.yiri2target(event_type).__name__ new_event_list = [] # 从本对象的监听器列表中查找并删除 target_wrapper = None for listener in self.listener_list: if listener['event_type'] == event_type and listener['callable'] == callback: target_wrapper = listener['wrapper'] self.listener_list.remove(listener) break if target_wrapper is None: raise Exception('未找到对应的监听器') for func in self.bot.event[nakuru_event_name]: if func.callable != target_wrapper: new_event_list.append(func) self.bot.event[nakuru_event_name] = new_event_list async def run_async(self): try: import requests resp = requests.get( url='http://{}:{}/get_login_info'.format(self.cfg['host'], self.cfg['http_port']), headers={'Authorization': 'Bearer ' + self.cfg['token'] if 'token' in self.cfg else ''}, timeout=5, proxies=None, ) if resp.status_code == 403: raise Exception('go-cqhttp拒绝访问,请检查配置文件中nakuru适配器的配置') self.bot_account_id = int(resp.json()['data']['user_id']) except Exception: raise Exception('获取go-cqhttp账号信息失败, 请检查是否已启动go-cqhttp并配置正确') await self.bot._run() while True: await asyncio.sleep(1) async def kill(self) -> bool: return False ================================================ FILE: src/langbot/pkg/platform/sources/legacy/nakuru.yaml ================================================ apiVersion: v1 kind: MessagePlatformAdapter metadata: name: nakuru label: en_US: Nakuru zh_Hans: Nakuru description: en_US: Nakuru Adapter zh_Hans: Nakuru 适配器(go-cqhttp),请查看文档了解使用方式 icon: nakuru.png spec: config: - name: host label: en_US: Host zh_Hans: 主机 type: string required: true default: "127.0.0.1" - name: http_port label: en_US: HTTP Port zh_Hans: HTTP端口 type: integer required: true default: 5700 - name: ws_port label: en_US: WebSocket Port zh_Hans: WebSocket端口 type: integer required: true default: 8080 - name: token label: en_US: Token zh_Hans: 令牌 type: string required: true default: "" execution: python: path: ./nakuru.py attr: NakuruAdapter ================================================ FILE: src/langbot/pkg/platform/sources/legacy/qqbotpy.py ================================================ from __future__ import annotations import logging import typing import datetime import re import traceback import botpy import botpy.message as botpy_message import botpy.types.message as botpy_message_type import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter from ....pipeline.longtext.strategies import forward from ....core import app from ....config import manager as cfg_mgr import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.message as platform_message from ...logger import EventLogger class OfficialGroupMessage(platform_events.GroupMessage): pass class OfficialFriendMessage(platform_events.FriendMessage): pass event_handler_mapping = { platform_events.GroupMessage: [ 'on_at_message_create', 'on_group_at_message_create', ], platform_events.FriendMessage: [ 'on_direct_message_create', 'on_c2c_message_create', ], } cached_message_ids = {} """由于QQ官方的消息id是字符串,而YiriMirai的消息id是整数,所以需要一个索引来进行转换""" id_index = 0 def save_msg_id(message_id: str) -> int: """保存消息id""" global id_index, cached_message_ids crt_index = id_index id_index += 1 cached_message_ids[str(crt_index)] = message_id return crt_index def char_to_value(char): """将单个字符转换为相应的数值。""" if '0' <= char <= '9': return ord(char) - ord('0') elif 'A' <= char <= 'Z': return ord(char) - ord('A') + 10 return ord(char) - ord('a') + 36 def digest(s: str) -> int: """计算字符串的hash值。""" # 取末尾的8位 sub_s = s[-10:] number = 0 base = 36 for i in range(len(sub_s)): number = number * base + char_to_value(sub_s[i]) return number K = typing.TypeVar('K') V = typing.TypeVar('V') class OpenIDMapping(typing.Generic[K, V]): map: dict[K, V] dump_func: typing.Callable digest_func: typing.Callable[[K], V] def __init__( self, map: dict[K, V], dump_func: typing.Callable, digest_func: typing.Callable[[K], V] = digest, ): self.map = map self.dump_func = dump_func self.digest_func = digest_func def __getitem__(self, key: K) -> V: return self.map[key] def __setitem__(self, key: K, value: V): self.map[key] = value self.dump_func() def __contains__(self, key: K) -> bool: return key in self.map def __delitem__(self, key: K): del self.map[key] self.dump_func() def getkey(self, value: V) -> K: return list(self.map.keys())[list(self.map.values()).index(value)] def save_openid(self, key: K) -> V: if key in self.map: return self.map[key] value = self.digest_func(key) self.map[key] = value self.dump_func() return value class OfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter): """QQ 官方消息转换器""" @staticmethod def yiri2target(message_chain: platform_message.MessageChain): """将 YiriMirai 的消息链转换为 QQ 官方消息""" msg_list = [] if type(message_chain) is platform_message.MessageChain: msg_list = message_chain.__root__ elif type(message_chain) is list: msg_list = message_chain elif type(message_chain) is str: msg_list = [platform_message.Plain(text=message_chain)] else: raise Exception('Unknown message type: ' + str(message_chain) + str(type(message_chain))) offcial_messages: list[dict] = [] """ { "type": "text", "content": "Hello World!" } { "type": "image", "content": "https://example.com/example.jpg" } """ # 遍历并转换 for component in msg_list: if type(component) is platform_message.Plain: offcial_messages.append({'type': 'text', 'content': component.text}) elif type(component) is platform_message.Image: if component.url is not None: offcial_messages.append({'type': 'image', 'content': component.url}) elif component.path is not None: offcial_messages.append({'type': 'file_image', 'content': component.path}) elif type(component) is platform_message.At: offcial_messages.append({'type': 'at', 'content': ''}) elif type(component) is platform_message.AtAll: print('上层组件要求发送 AtAll 消息,但 QQ 官方 API 不支持此消息类型,忽略此消息。') elif type(component) is platform_message.Voice: print('上层组件要求发送 Voice 消息,但 QQ 官方 API 不支持此消息类型,忽略此消息。') elif type(component) is forward.Forward: # 转发消息 yiri_forward_node_list = component.node_list # 遍历并转换 for yiri_forward_node in yiri_forward_node_list: try: message_chain = yiri_forward_node.message_chain # 平铺 offcial_messages.extend(OfficialMessageConverter.yiri2target(message_chain)) except Exception: import traceback traceback.print_exc() return offcial_messages @staticmethod def extract_message_chain_from_obj( message: typing.Union[ botpy_message.Message, botpy_message.DirectMessage, botpy_message.GroupMessage, botpy_message.C2CMessage, ], message_id: str = None, bot_account_id: int = 0, ) -> platform_message.MessageChain: yiri_msg_list = [] # 存id yiri_msg_list.append(platform_message.Source(id=save_msg_id(message_id), time=datetime.datetime.now())) if type(message) not in [botpy_message.DirectMessage, botpy_message.C2CMessage]: yiri_msg_list.append(platform_message.At(target=bot_account_id)) if hasattr(message, 'mentions'): for mention in message.mentions: if mention.bot: continue yiri_msg_list.append(platform_message.At(target=mention.id)) for attachment in message.attachments: if attachment.content_type.startswith('image'): yiri_msg_list.append(platform_message.Image(url=attachment.url)) else: logging.warning('不支持的附件类型:' + attachment.content_type + ',忽略此附件。') content = re.sub(r'<@!\d+>', '', str(message.content)) if content.strip() != '': yiri_msg_list.append(platform_message.Plain(text=content)) chain = platform_message.MessageChain(yiri_msg_list) return chain class OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter): """事件转换器""" def __init__(self): pass def yiri2target(self, event: typing.Type[platform_events.Event]): if event == platform_events.GroupMessage: return botpy_message.Message elif event == platform_events.FriendMessage: return botpy_message.DirectMessage else: raise Exception('未支持转换的事件类型(YiriMirai -> Official): ' + str(event)) def target2yiri( self, event: typing.Union[ botpy_message.Message, botpy_message.DirectMessage, botpy_message.GroupMessage, botpy_message.C2CMessage, ], ) -> platform_events.Event: if isinstance(event, botpy_message.Message): # 频道内,转群聊事件 permission = 'MEMBER' if '2' in event.member.roles: permission = 'ADMINISTRATOR' elif '4' in event.member.roles: permission = 'OWNER' return platform_events.GroupMessage( sender=platform_entities.GroupMember( id=event.author.id, member_name=event.author.username, permission=permission, group=platform_entities.Group( id=event.channel_id, name=event.author.username, permission=platform_entities.Permission.Member, ), special_title='', ), message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id), time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), ) elif isinstance(event, botpy_message.DirectMessage): # 频道私聊,转私聊事件 return platform_events.FriendMessage( sender=platform_entities.Friend( id=event.guild_id, nickname=event.author.username, remark=event.author.username, ), message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id), time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), ) elif isinstance(event, botpy_message.GroupMessage): # 群聊,转群聊事件 author_member_id = event.author.member_openid return OfficialGroupMessage( sender=platform_entities.GroupMember( id=author_member_id, member_name=author_member_id, permission='MEMBER', group=platform_entities.Group( id=event.group_openid, name=author_member_id, permission=platform_entities.Permission.Member, ), special_title='', ), message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id), time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), ) elif isinstance(event, botpy_message.C2CMessage): # 私聊,转私聊事件 user_id_alter = event.author.user_openid return OfficialFriendMessage( sender=platform_entities.Friend( id=user_id_alter, nickname=user_id_alter, remark=user_id_alter, ), message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id), time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), ) class OfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): """QQ 官方消息适配器""" bot: botpy.Client = None bot_account_id: int = 0 message_converter: OfficialMessageConverter event_converter: OfficialEventConverter cfg: dict = None cached_official_messages: dict = {} """缓存的 qq-botpy 框架消息对象 message_id: botpy_message.Message | botpy_message.DirectMessage """ ap: app.Application metadata: cfg_mgr.ConfigManager = None group_msg_seq = None c2c_msg_seq = None def __init__(self, cfg: dict, ap: app.Application, logger: EventLogger): """初始化适配器""" self.cfg = cfg self.ap = ap self.logger = logger self.group_msg_seq = 1 self.c2c_msg_seq = 1 switchs = {} for intent in cfg['intents']: switchs[intent] = True del cfg['intents'] intents = botpy.Intents(**switchs) self.bot = botpy.Client(intents=intents) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): message_list = self.message_converter.yiri2target(message) for msg in message_list: args = {} if msg['type'] == 'text': args['content'] = msg['content'] elif msg['type'] == 'image': args['image'] = msg['content'] elif msg['type'] == 'file_image': args['file_image'] = msg['content'] else: continue if target_type == 'group': args['channel_id'] = str(target_id) await self.bot.api.post_message(**args) elif target_type == 'person': args['guild_id'] = str(target_id) await self.bot.api.post_dms(**args) async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): message_list = self.message_converter.yiri2target(message) for msg in message_list: args = {} if msg['type'] == 'text': args['content'] = msg['content'] elif msg['type'] == 'image': args['image'] = msg['content'] elif msg['type'] == 'file_image': args['file_image'] = msg['content'] else: continue if quote_origin: args['message_reference'] = botpy_message_type.Reference( message_id=cached_message_ids[str(message_source.message_chain.message_id)] ) if isinstance(message_source, platform_events.GroupMessage): args['channel_id'] = str(message_source.sender.group.id) args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)] await self.bot.api.post_message(**args) elif isinstance(message_source, platform_events.FriendMessage): args['guild_id'] = str(message_source.sender.id) args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)] await self.bot.api.post_dms(**args) elif isinstance(message_source, OfficialGroupMessage): if 'file_image' in args: # 暂不支持发送文件图片 continue args['group_openid'] = message_source.sender.group.id if 'image' in args: uploadMedia = await self.bot.api.post_group_file( group_openid=args['group_openid'], file_type=1, url=str(args['image']), ) del args['image'] args['media'] = uploadMedia args['msg_type'] = 7 args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)] args['msg_seq'] = self.group_msg_seq self.group_msg_seq += 1 await self.bot.api.post_group_message(**args) elif isinstance(message_source, OfficialFriendMessage): if 'file_image' in args: continue args['openid'] = message_source.sender.id if 'image' in args: uploadMedia = await self.bot.api.post_c2c_file( openid=args['openid'], file_type=1, url=str(args['image']) ) del args['image'] args['media'] = uploadMedia args['msg_type'] = 7 args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)] args['msg_seq'] = self.c2c_msg_seq self.c2c_msg_seq += 1 await self.bot.api.post_c2c_message(**args) async def is_muted(self, group_id: int) -> bool: return False def register_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): try: async def wrapper( message: typing.Union[ botpy_message.Message, botpy_message.DirectMessage, botpy_message.GroupMessage, ], ): self.cached_official_messages[str(message.id)] = message await callback(self.event_converter.target2yiri(message), self) for event_handler in event_handler_mapping[event_type]: setattr(self.bot, event_handler, wrapper) except Exception as e: self.logger.error(f'Error in qqbotpy callback: {traceback.format_exc()}') raise e def unregister_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): delattr(self.bot, event_handler_mapping[event_type]) async def run_async(self): self.metadata = self.ap.adapter_qq_botpy_meta self.message_converter = OfficialMessageConverter() self.event_converter = OfficialEventConverter() self.cfg['ret_coro'] = True await self.logger.info('运行 QQ 官方适配器') await (await self.bot.start(**self.cfg)) async def kill(self) -> bool: if not self.bot.is_closed(): await self.bot.close() return True ================================================ FILE: src/langbot/pkg/platform/sources/legacy/qqbotpy.yaml ================================================ apiVersion: v1 kind: MessagePlatformAdapter metadata: name: qq-botpy label: en_US: QQBotPy zh_Hans: QQBotPy description: en_US: QQ Official API (WebSocket) zh_Hans: QQ 官方 API (WebSocket),请查看文档了解使用方式 icon: qqbotpy.svg spec: config: - name: appid label: en_US: App ID zh_Hans: 应用ID type: string required: true default: "" - name: secret label: en_US: Secret zh_Hans: 密钥 type: string required: true default: "" - name: intents label: en_US: Intents zh_Hans: 权限 type: array required: true default: [] items: type: string execution: python: path: ./qqbotpy.py attr: OfficialAdapter ================================================ FILE: src/langbot/pkg/platform/sources/line.py ================================================ import typing import quart import traceback import asyncio import base64 import datetime import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities from ..logger import EventLogger from linebot.v3 import WebhookHandler from linebot.v3.exceptions import InvalidSignatureError from linebot.v3.messaging import Configuration, ApiClient, MessagingApi, ReplyMessageRequest, TextMessage, ImageMessage from linebot.v3.webhooks import ( MessageEvent, TextMessageContent, ImageMessageContent, VideoMessageContent, AudioMessageContent, ) # from linebot import WebhookParser from linebot.v3.webhook import WebhookParser from linebot.v3.messaging import MessagingApiBlob class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target(message_chain: platform_message.MessageChain, api_client: ApiClient) -> typing.Tuple[list]: content_list = [] for component in message_chain: if isinstance(component, platform_message.At): content_list.append({'type': 'at', 'target': component.target}) elif isinstance(component, platform_message.Plain): content_list.append({'type': 'text', 'content': component.text}) elif isinstance(component, platform_message.Image): # Only add image if it has a valid URL if component.url: content_list.append({'type': 'image', 'image': component.url}) elif isinstance(component, platform_message.Voice): content_list.append({'type': 'voice', 'url': component.url, 'length': component.length}) return content_list @staticmethod async def target2yiri(message, bot_client) -> platform_message.MessageChain: lb_msg_list = [] msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000) lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time)) if isinstance(message.message, TextMessageContent): lb_msg_list.append(platform_message.Plain(text=message.message.text)) elif isinstance(message.message, AudioMessageContent): pass elif isinstance(message.message, VideoMessageContent): pass elif isinstance(message.message, ImageMessageContent): message_content = MessagingApiBlob(bot_client).get_message_content(message.message.id) base64_string = base64.b64encode(message_content).decode('utf-8') # 如果需要Data URI格式(用于直接嵌入HTML等) # 首先需要知道图片类型,LINE图片通常是JPEG data_uri = f'data:image/jpeg;base64,{base64_string}' lb_msg_list.append(platform_message.Image(base64=data_uri)) return platform_message.MessageChain(lb_msg_list) class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def yiri2target( event: platform_events.MessageEvent, ) -> MessageEvent: pass @staticmethod async def target2yiri(event, bot_client) -> platform_events.Event: message_chain = await LINEMessageConverter.target2yiri(event, bot_client) if event.source.type == 'user': return platform_events.FriendMessage( sender=platform_entities.Friend( id=event.message.id, nickname=event.source.user_id, remark='', ), message_chain=message_chain, time=event.timestamp, source_platform_object=event, ) else: return platform_events.GroupMessage( sender=platform_entities.GroupMember( id=event.event.sender.sender_id.open_id, member_name=event.event.sender.sender_id.union_id, permission=platform_entities.Permission.Member, group=platform_entities.Group( id=event.message.id, name='', permission=platform_entities.Permission.Member, ), special_title='', ), message_chain=message_chain, time=event.timestamp, source_platform_object=event, ) class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: MessagingApi api_client: ApiClient parser: WebhookParser bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识 message_converter: LINEMessageConverter event_converter: LINEEventConverter listeners: typing.Dict[ typing.Type[platform_events.Event], typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], ] config: dict bot_uuid: str = None card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片 seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识 def __init__(self, config: dict, logger: EventLogger): configuration = Configuration(access_token=config['channel_access_token']) line_webhook = WebhookHandler(config['channel_secret']) parser = WebhookParser(config['channel_secret']) api_client = ApiClient(configuration) bot_account_id = config.get('bot_account_id', 'langbot') super().__init__( config=config, logger=logger, listeners={}, card_id_dict={}, seq=1, event_converter=LINEEventConverter(), message_converter=LINEMessageConverter(), line_webhook=line_webhook, parser=parser, configuration=configuration, api_client=api_client, bot=MessagingApi(api_client), bot_account_id=bot_account_id, ) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): pass async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): content_list = await self.message_converter.yiri2target(message, self.api_client) for content in content_list: if content['type'] == 'text': self.bot.reply_message_with_http_info( ReplyMessageRequest( reply_token=message_source.source_platform_object.reply_token, messages=[TextMessage(text=content['content'])], ) ) elif content['type'] == 'image': # LINE ImageMessage requires original_content_url and preview_image_url image_url = content['image'] self.bot.reply_message_with_http_info( ReplyMessageRequest( reply_token=message_source.source_platform_object.reply_token, messages=[ImageMessage(original_content_url=image_url, preview_image_url=image_url)], ) ) async def is_muted(self, group_id: int) -> bool: return False def register_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): self.listeners[event_type] = callback def unregister_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): self.listeners.pop(event_type) def set_bot_uuid(self, bot_uuid: str): """设置 bot UUID(用于生成 webhook URL)""" self.bot_uuid = bot_uuid async def handle_unified_webhook(self, bot_uuid: str, path: str, request): """处理统一 webhook 请求。 Args: bot_uuid: Bot 的 UUID path: 子路径(如果有的话) request: Quart Request 对象 Returns: 响应数据 """ try: signature = request.headers.get('X-Line-Signature') body = await request.get_data(as_text=True) # Check if signature header exists if not signature: await self.logger.warning('Missing X-Line-Signature header') return quart.Response('Missing X-Line-Signature header', status=400) try: events = self.parser.parse(body, signature) # 解密解析消息 except InvalidSignatureError: await self.logger.info( f'Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}' ) return quart.Response('Invalid signature', status=400) # 处理事件 if events and len(events) > 0: lb_event = await self.event_converter.target2yiri(events[0], self.api_client) if lb_event.__class__ in self.listeners: await self.listeners[lb_event.__class__](lb_event, self) return {'code': 200, 'message': 'ok'} except Exception: await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}') print(traceback.format_exc()) return {'code': 500, 'message': 'error'} async def run_async(self): # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 # 打印 webhook 回调地址 async def keep_alive(): while True: await asyncio.sleep(1) await keep_alive() async def kill(self) -> bool: pass ================================================ FILE: src/langbot/pkg/platform/sources/line.yaml ================================================ apiVersion: v1 kind: MessagePlatformAdapter metadata: name: LINE label: en_US: LINE zh_Hans: LINE description: en_US: LINE Adapter zh_Hans: LINE适配器,请查看文档了解使用方式 ja_JP: LINEアダプター、ドキュメントを参照してください zh_Hant: LINE適配器,請查看文檔了解使用方式 icon: line.png spec: config: - name: channel_access_token label: en_US: Channel access token zh_Hans: 频道访问令牌 ja_JP: チャンネルアクセストークン zh_Hant: 頻道訪問令牌 type: string required: true default: "" - name: channel_secret label: en_US: Channel secret zh_Hans: 消息密钥 ja_JP: チャンネルシークレット zh_Hant: 消息密钥 description: en_US: Only valid when webhook mode is enabled, please fill in the encrypt key zh_Hans: 请填写加密密钥 ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください zh_Hant: 請填寫加密密钥 type: string required: true default: "" execution: python: path: ./line.py attr: LINEAdapter ================================================ FILE: src/langbot/pkg/platform/sources/officialaccount.py ================================================ from __future__ import annotations import typing import asyncio import traceback import pydantic import datetime import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter from langbot.libs.official_account_api.oaevent import OAEvent from langbot.libs.official_account_api.api import OAClient from langbot.libs.official_account_api.api import OAClientForLongerResponse import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events from ..logger import EventLogger class OAMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target(message_chain: platform_message.MessageChain): for msg in message_chain: if type(msg) is platform_message.Plain: return msg.text @staticmethod async def target2yiri(message: str, message_id=-1): yiri_msg_list = [] yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now())) yiri_msg_list.append(platform_message.Plain(text=message)) chain = platform_message.MessageChain(yiri_msg_list) return chain class OAEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def target2yiri(event: OAEvent): if event.type == 'text': yiri_chain = await OAMessageConverter.target2yiri(event.message, event.message_id) friend = platform_entities.Friend( id=event.user_id, nickname=str(event.user_id), remark='', ) return platform_events.FriendMessage( sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event, ) else: return None class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): message_converter: OAMessageConverter = OAMessageConverter() event_converter: OAEventConverter = OAEventConverter() bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True) bot_uuid: str = None def __init__(self, config: dict, logger: EventLogger): # 校验必填项 required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode'] missing_keys = [k for k in required_keys if k not in config] if missing_keys: raise Exception(f'OfficialAccount 缺少配置项: {missing_keys}') # 创建运行时 bot 对象,始终使用统一 webhook 模式 if config['Mode'] == 'drop': bot = OAClient( token=config['token'], EncodingAESKey=config['EncodingAESKey'], Appsecret=config['AppSecret'], AppID=config['AppID'], logger=logger, unified_mode=True, api_base_url=config.get('api_base_url', 'https://api.weixin.qq.com'), ) elif config['Mode'] == 'passive': bot = OAClientForLongerResponse( token=config['token'], EncodingAESKey=config['EncodingAESKey'], Appsecret=config['AppSecret'], AppID=config['AppID'], LoadingMessage=config.get('LoadingMessage', ''), logger=logger, unified_mode=True, api_base_url=config.get('api_base_url', 'https://api.weixin.qq.com'), ) else: raise KeyError('请设置微信公众号通信模式') bot_account_id = config.get('AppID', '') super().__init__( bot=bot, bot_account_id=bot_account_id, config=config, logger=logger, ) async def reply_message( self, message_source: platform_events.FriendMessage, message: platform_message.MessageChain, quote_origin: bool = False, ): content = await OAMessageConverter.yiri2target(message) if isinstance(self.bot, OAClient): await self.bot.set_message(message_source.message_chain.message_id, content) elif isinstance(self.bot, OAClientForLongerResponse): from_user = message_source.sender.id await self.bot.set_message(from_user, message_source.message_chain.message_id, content) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): pass def register_listener( self, event_type: type, callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): async def on_message(event: OAEvent): self.bot_account_id = event.receiver_id try: return await callback(await self.event_converter.target2yiri(event), self) except Exception: await self.logger.error(f'Error in officialaccount callback: {traceback.format_exc()}') if event_type == platform_events.FriendMessage: self.bot.on_message('text')(on_message) elif event_type == platform_events.GroupMessage: pass def set_bot_uuid(self, bot_uuid: str): """设置 bot UUID(用于生成 webhook URL)""" self.bot_uuid = bot_uuid async def handle_unified_webhook(self, bot_uuid: str, path: str, request): """处理统一 webhook 请求。 Args: bot_uuid: Bot 的 UUID path: 子路径(如果有的话) request: Quart Request 对象 Returns: 响应数据 """ return await self.bot.handle_unified_webhook(request) async def run_async(self): # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 async def keep_alive(): while True: await asyncio.sleep(1) await keep_alive() async def kill(self) -> bool: return False async def unregister_listener( self, event_type: type, callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): return super().unregister_listener(event_type, callback) async def is_muted( self, group_id: str, ) -> bool: pass ================================================ FILE: src/langbot/pkg/platform/sources/officialaccount.yaml ================================================ apiVersion: v1 kind: MessagePlatformAdapter metadata: name: officialaccount label: en_US: Official Account zh_Hans: 微信公众号 description: en_US: Official Account Adapter zh_Hans: 微信公众号适配器,请查看文档了解使用方式 icon: officialaccount.png spec: config: - name: token label: en_US: Token zh_Hans: 令牌 type: string required: true default: "" - name: EncodingAESKey label: en_US: EncodingAESKey zh_Hans: 消息加解密密钥 type: string required: true default: "" - name: AppID label: en_US: App ID zh_Hans: 应用ID type: string required: true default: "" - name: AppSecret label: en_US: App Secret zh_Hans: 应用密钥 type: string required: true default: "" - name: Mode label: en_US: Mode zh_Hans: 接入模式 type: string required: true default: "drop" - name: LoadingMessage label: en_US: Loading Message zh_Hans: 加载消息 type: string required: true default: "AI正在思考中,请发送任意内容获取回复。" - name: api_base_url label: en_US: API Base URL zh_Hans: API 基础 URL description: en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation. zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API,可根据文档修改此项 type: string required: false default: "https://api.weixin.qq.com" execution: python: path: ./officialaccount.py attr: OfficialAccountAdapter ================================================ FILE: src/langbot/pkg/platform/sources/qqofficial.py ================================================ from __future__ import annotations import typing import asyncio import traceback import datetime import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities from langbot.libs.qq_official_api.api import QQOfficialClient from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent from ...utils import image from ..logger import EventLogger class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target(message_chain: platform_message.MessageChain): content_list = [] # 只实现了发文字 for msg in message_chain: if type(msg) is platform_message.Plain: content_list.append( { 'type': 'text', 'content': msg.text, } ) return content_list @staticmethod async def target2yiri(message: str, message_id: str, pic_url: str, content_type): yiri_msg_list = [] yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now())) if pic_url is not None: base64_url = await image.get_qq_official_image_base64(pic_url=pic_url, content_type=content_type) yiri_msg_list.append(platform_message.Image(base64=base64_url)) yiri_msg_list.append(platform_message.Plain(text=message)) chain = platform_message.MessageChain(yiri_msg_list) return chain class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def yiri2target(event: platform_events.MessageEvent) -> QQOfficialEvent: return event.source_platform_object @staticmethod async def target2yiri(event: QQOfficialEvent): """ QQ官方消息转换为LB对象 """ yiri_chain = await QQOfficialMessageConverter.target2yiri( message=event.content, message_id=event.d_id, pic_url=event.attachments, content_type=event.content_type, ) if event.t == 'C2C_MESSAGE_CREATE': friend = platform_entities.Friend( id=event.user_openid, nickname=event.t, remark='', ) return platform_events.FriendMessage( sender=friend, message_chain=yiri_chain, time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), source_platform_object=event, ) if event.t == 'DIRECT_MESSAGE_CREATE': friend = platform_entities.Friend( id=event.guild_id, nickname=event.t, remark='', ) return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, source_platform_object=event) if event.t == 'GROUP_AT_MESSAGE_CREATE': yiri_chain.insert(0, platform_message.At(target='justbot')) sender = platform_entities.GroupMember( id=event.group_openid, member_name=event.t, permission='MEMBER', group=platform_entities.Group( id=event.group_openid, name='MEMBER', permission=platform_entities.Permission.Member, ), special_title='', ) time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()) return platform_events.GroupMessage( sender=sender, message_chain=yiri_chain, time=time, source_platform_object=event, ) if event.t == 'AT_MESSAGE_CREATE': yiri_chain.insert(0, platform_message.At(target='justbot')) sender = platform_entities.GroupMember( id=event.channel_id, member_name=event.t, permission='MEMBER', group=platform_entities.Group( id=event.channel_id, name='MEMBER', permission=platform_entities.Permission.Member, ), special_title='', ) time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()) return platform_events.GroupMessage( sender=sender, message_chain=yiri_chain, time=time, source_platform_object=event, ) class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: QQOfficialClient config: dict bot_account_id: str bot_uuid: str = None message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter() event_converter: QQOfficialEventConverter = QQOfficialEventConverter() def __init__(self, config: dict, logger: EventLogger): bot = QQOfficialClient( app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True ) super().__init__( config=config, logger=logger, bot=bot, bot_account_id=config['appid'], ) async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): qq_official_event = await QQOfficialEventConverter.yiri2target( message_source, ) content_list = await QQOfficialMessageConverter.yiri2target(message) # 私聊消息 if qq_official_event.t == 'C2C_MESSAGE_CREATE': for content in content_list: if content['type'] == 'text': await self.bot.send_private_text_msg( qq_official_event.user_openid, content['content'], qq_official_event.d_id, ) # 群聊消息 if qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE': for content in content_list: if content['type'] == 'text': await self.bot.send_group_text_msg( qq_official_event.group_openid, content['content'], qq_official_event.d_id, ) # 频道群聊 if qq_official_event.t == 'AT_MESSAGE_CREATE': for content in content_list: if content['type'] == 'text': await self.bot.send_channle_group_text_msg( qq_official_event.channel_id, content['content'], qq_official_event.d_id, ) # 频道私聊 if qq_official_event.t == 'DIRECT_MESSAGE_CREATE': for content in content_list: if content['type'] == 'text': await self.bot.send_channle_private_text_msg( qq_official_event.guild_id, content['content'], qq_official_event.d_id, ) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): pass def register_listener( self, event_type: typing.Type[platform_events.Event], callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): async def on_message(event: QQOfficialEvent): self.bot_account_id = 'justbot' try: return await callback(await self.event_converter.target2yiri(event), self) except Exception: await self.logger.error(f'Error in qqofficial callback: {traceback.format_exc()}') if event_type == platform_events.FriendMessage: self.bot.on_message('DIRECT_MESSAGE_CREATE')(on_message) self.bot.on_message('C2C_MESSAGE_CREATE')(on_message) elif event_type == platform_events.GroupMessage: self.bot.on_message('GROUP_AT_MESSAGE_CREATE')(on_message) self.bot.on_message('AT_MESSAGE_CREATE')(on_message) def set_bot_uuid(self, bot_uuid: str): """设置 bot UUID(用于生成 webhook URL)""" self.bot_uuid = bot_uuid async def handle_unified_webhook(self, bot_uuid: str, path: str, request): """处理统一 webhook 请求。 Args: bot_uuid: Bot 的 UUID path: 子路径(如果有的话) request: Quart Request 对象 Returns: 响应数据 """ return await self.bot.handle_unified_webhook(request) async def run_async(self): # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 async def keep_alive(): while True: await asyncio.sleep(1) await keep_alive() async def kill(self) -> bool: return False def unregister_listener( self, event_type: type, callback: typing.Callable[ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None ], ): return super().unregister_listener(event_type, callback) ================================================ FILE: src/langbot/pkg/platform/sources/qqofficial.yaml ================================================ apiVersion: v1 kind: MessagePlatformAdapter metadata: name: qqofficial label: en_US: QQ Official API zh_Hans: QQ 官方 API description: en_US: QQ Official API (Webhook) zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式 icon: qqofficial.svg spec: config: - name: appid label: en_US: App ID zh_Hans: 应用ID type: string required: true default: "" - name: secret label: en_US: Secret zh_Hans: 密钥 type: string required: true default: "" - name: token label: en_US: Token zh_Hans: 令牌 type: string required: true default: "" execution: python: path: ./qqofficial.py attr: QQOfficialAdapter ================================================ FILE: src/langbot/pkg/platform/sources/satori.py ================================================ from __future__ import annotations import typing import time import datetime import json import asyncio import traceback import re import base64 import aiohttp import pydantic import websockets import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger class SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter): """Convert between LangBot MessageChain and Satori message format""" @staticmethod async def yiri2target(message_chain: platform_message.MessageChain, adapter: 'SatoriAdapter') -> str: """Convert LangBot MessageChain to Satori message format""" content_parts = [] for component in message_chain: if isinstance(component, platform_message.Plain): text = component.text.replace('&', '&').replace('<', '<').replace('>', '>') content_parts.append(text) elif isinstance(component, platform_message.Image): # Prefer URL over base64 to avoid buffer overflow issues with large images if component.url: content_parts.append(f'') elif hasattr(component, 'base64') and component.base64: # Process base64 data base64_data = component.base64 # Remove whitespace that might corrupt the data base64_data = base64_data.replace('\n', '').replace('\r', '').replace(' ', '') # Check size - if too large, try to upload MAX_INLINE_SIZE = 32 * 1024 # 32KB limit for inline base64 # Extract raw base64 and mime type raw_b64 = base64_data mime_type = 'image/png' if base64_data.startswith('data:'): try: header, raw_b64 = base64_data.split(',', 1) if ';' in header: mime_type = header.split(':')[1].split(';')[0] except (ValueError, IndexError): pass if len(raw_b64) > MAX_INLINE_SIZE: # Try to upload large image try: # Fix base64 padding if needed padding = 4 - len(raw_b64) % 4 if padding != 4: raw_b64 += '=' * padding image_bytes = base64.b64decode(raw_b64) uploaded_url = await adapter.upload_image(image_bytes, mime_type) if uploaded_url: await adapter.logger.info(f'Satori 图片上传成功: {len(image_bytes)} 字节') content_parts.append(f'') else: # Upload failed, use inline (may fail) await adapter.logger.warning('Satori 图片上传失败,使用内联模式') content_parts.append(f'') except Exception as e: await adapter.logger.error(f'Satori 图片处理失败: {e}') content_parts.append(f'') else: # Small image, use inline content_parts.append(f'') elif isinstance(component, platform_message.At): if component.target: content_parts.append(f'') elif isinstance(component, platform_message.AtAll): content_parts.append('') elif isinstance(component, platform_message.Reply): content_parts.append(f'') elif isinstance(component, platform_message.Quote): content_parts.append(f'') elif isinstance(component, platform_message.Face): # Satori中的表情可以使用emoticon元素 face_id = getattr(component, 'face_id', 'unknown') content_parts.append(f'') elif isinstance(component, platform_message.Voice): if hasattr(component, 'url') and component.url: content_parts.append(f'