calvyn-code 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/README.zh-CN.md +180 -0
- package/acp_adapter/__init__.py +1 -0
- package/acp_adapter/__main__.py +5 -0
- package/acp_adapter/auth.py +68 -0
- package/acp_adapter/bootstrap/__init__.py +0 -0
- package/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 +288 -0
- package/acp_adapter/bootstrap/bootstrap_browser_tools.sh +399 -0
- package/acp_adapter/entry.py +292 -0
- package/acp_adapter/events.py +265 -0
- package/acp_adapter/permissions.py +148 -0
- package/acp_adapter/server.py +1713 -0
- package/acp_adapter/session.py +629 -0
- package/acp_adapter/tools.py +1180 -0
- package/agent/__init__.py +6 -0
- package/agent/__pycache__/__init__.cpython-312.pyc +0 -0
- package/agent/__pycache__/account_usage.cpython-312.pyc +0 -0
- package/agent/__pycache__/anthropic_adapter.cpython-312.pyc +0 -0
- package/agent/__pycache__/async_utils.cpython-312.pyc +0 -0
- package/agent/__pycache__/auxiliary_client.cpython-312.pyc +0 -0
- package/agent/__pycache__/codex_responses_adapter.cpython-312.pyc +0 -0
- package/agent/__pycache__/context_compressor.cpython-312.pyc +0 -0
- package/agent/__pycache__/context_engine.cpython-312.pyc +0 -0
- package/agent/__pycache__/context_references.cpython-312.pyc +0 -0
- package/agent/__pycache__/credential_pool.cpython-312.pyc +0 -0
- package/agent/__pycache__/curator.cpython-312.pyc +0 -0
- package/agent/__pycache__/display.cpython-312.pyc +0 -0
- package/agent/__pycache__/error_classifier.cpython-312.pyc +0 -0
- package/agent/__pycache__/file_safety.cpython-312.pyc +0 -0
- package/agent/__pycache__/google_code_assist.cpython-312.pyc +0 -0
- package/agent/__pycache__/google_oauth.cpython-312.pyc +0 -0
- package/agent/__pycache__/i18n.cpython-312.pyc +0 -0
- package/agent/__pycache__/image_gen_provider.cpython-312.pyc +0 -0
- package/agent/__pycache__/image_gen_registry.cpython-312.pyc +0 -0
- package/agent/__pycache__/insights.cpython-312.pyc +0 -0
- package/agent/__pycache__/lmstudio_reasoning.cpython-312.pyc +0 -0
- package/agent/__pycache__/manual_compression_feedback.cpython-312.pyc +0 -0
- package/agent/__pycache__/markdown_tables.cpython-312.pyc +0 -0
- package/agent/__pycache__/memory_manager.cpython-312.pyc +0 -0
- package/agent/__pycache__/memory_provider.cpython-312.pyc +0 -0
- package/agent/__pycache__/model_metadata.cpython-312.pyc +0 -0
- package/agent/__pycache__/models_dev.cpython-312.pyc +0 -0
- package/agent/__pycache__/moonshot_schema.cpython-312.pyc +0 -0
- package/agent/__pycache__/onboarding.cpython-312.pyc +0 -0
- package/agent/__pycache__/portal_tags.cpython-312.pyc +0 -0
- package/agent/__pycache__/prompt_builder.cpython-312.pyc +0 -0
- package/agent/__pycache__/prompt_caching.cpython-312.pyc +0 -0
- package/agent/__pycache__/redact.cpython-312.pyc +0 -0
- package/agent/__pycache__/retry_utils.cpython-312.pyc +0 -0
- package/agent/__pycache__/shell_hooks.cpython-312.pyc +0 -0
- package/agent/__pycache__/skill_commands.cpython-312.pyc +0 -0
- package/agent/__pycache__/skill_preprocessing.cpython-312.pyc +0 -0
- package/agent/__pycache__/skill_utils.cpython-312.pyc +0 -0
- package/agent/__pycache__/subdirectory_hints.cpython-312.pyc +0 -0
- package/agent/__pycache__/think_scrubber.cpython-312.pyc +0 -0
- package/agent/__pycache__/title_generator.cpython-312.pyc +0 -0
- package/agent/__pycache__/tool_guardrails.cpython-312.pyc +0 -0
- package/agent/__pycache__/tool_result_classification.cpython-312.pyc +0 -0
- package/agent/__pycache__/trajectory.cpython-312.pyc +0 -0
- package/agent/__pycache__/usage_pricing.cpython-312.pyc +0 -0
- package/agent/__pycache__/video_gen_provider.cpython-312.pyc +0 -0
- package/agent/__pycache__/video_gen_registry.cpython-312.pyc +0 -0
- package/agent/__pycache__/web_search_provider.cpython-312.pyc +0 -0
- package/agent/__pycache__/web_search_registry.cpython-312.pyc +0 -0
- package/agent/account_usage.py +326 -0
- package/agent/anthropic_adapter.py +2087 -0
- package/agent/async_utils.py +68 -0
- package/agent/auxiliary_client.py +4893 -0
- package/agent/bedrock_adapter.py +1276 -0
- package/agent/codex_responses_adapter.py +1084 -0
- package/agent/context_compressor.py +1583 -0
- package/agent/context_engine.py +211 -0
- package/agent/context_references.py +519 -0
- package/agent/copilot_acp_client.py +684 -0
- package/agent/credential_pool.py +1780 -0
- package/agent/credential_sources.py +449 -0
- package/agent/curator.py +1782 -0
- package/agent/curator_backup.py +694 -0
- package/agent/display.py +987 -0
- package/agent/error_classifier.py +1058 -0
- package/agent/file_safety.py +112 -0
- package/agent/gemini_cloudcode_adapter.py +909 -0
- package/agent/gemini_native_adapter.py +971 -0
- package/agent/gemini_schema.py +99 -0
- package/agent/google_code_assist.py +452 -0
- package/agent/google_oauth.py +1062 -0
- package/agent/i18n.py +258 -0
- package/agent/image_gen_provider.py +243 -0
- package/agent/image_gen_registry.py +145 -0
- package/agent/image_routing.py +301 -0
- package/agent/insights.py +931 -0
- package/agent/lmstudio_reasoning.py +48 -0
- package/agent/lsp/__init__.py +106 -0
- package/agent/lsp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/cli.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/client.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/eventlog.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/manager.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/protocol.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/servers.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/workspace.cpython-312.pyc +0 -0
- package/agent/lsp/cli.py +308 -0
- package/agent/lsp/client.py +930 -0
- package/agent/lsp/eventlog.py +213 -0
- package/agent/lsp/install.py +376 -0
- package/agent/lsp/manager.py +644 -0
- package/agent/lsp/protocol.py +196 -0
- package/agent/lsp/range_shift.py +149 -0
- package/agent/lsp/reporter.py +78 -0
- package/agent/lsp/servers.py +1040 -0
- package/agent/lsp/workspace.py +223 -0
- package/agent/manual_compression_feedback.py +49 -0
- package/agent/markdown_tables.py +309 -0
- package/agent/memory_manager.py +556 -0
- package/agent/memory_provider.py +279 -0
- package/agent/model_metadata.py +1827 -0
- package/agent/models_dev.py +724 -0
- package/agent/moonshot_schema.py +231 -0
- package/agent/nous_rate_guard.py +326 -0
- package/agent/onboarding.py +193 -0
- package/agent/plugin_llm.py +1046 -0
- package/agent/portal_tags.py +64 -0
- package/agent/prompt_builder.py +1457 -0
- package/agent/prompt_caching.py +79 -0
- package/agent/rate_limit_tracker.py +246 -0
- package/agent/redact.py +403 -0
- package/agent/retry_utils.py +57 -0
- package/agent/shell_hooks.py +837 -0
- package/agent/skill_commands.py +502 -0
- package/agent/skill_preprocessing.py +131 -0
- package/agent/skill_utils.py +512 -0
- package/agent/subdirectory_hints.py +224 -0
- package/agent/think_scrubber.py +386 -0
- package/agent/title_generator.py +171 -0
- package/agent/tool_guardrails.py +458 -0
- package/agent/tool_result_classification.py +26 -0
- package/agent/trajectory.py +56 -0
- package/agent/transports/__init__.py +68 -0
- package/agent/transports/__pycache__/__init__.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/anthropic.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/base.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/bedrock.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/chat_completions.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/codex.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/types.cpython-312.pyc +0 -0
- package/agent/transports/anthropic.py +179 -0
- package/agent/transports/base.py +89 -0
- package/agent/transports/bedrock.py +154 -0
- package/agent/transports/chat_completions.py +614 -0
- package/agent/transports/codex.py +283 -0
- package/agent/transports/codex_app_server.py +368 -0
- package/agent/transports/codex_app_server_session.py +810 -0
- package/agent/transports/codex_event_projector.py +312 -0
- package/agent/transports/hermes_tools_mcp_server.py +233 -0
- package/agent/transports/types.py +162 -0
- package/agent/usage_pricing.py +877 -0
- package/agent/video_gen_provider.py +300 -0
- package/agent/video_gen_registry.py +117 -0
- package/agent/web_search_provider.py +221 -0
- package/agent/web_search_registry.py +262 -0
- package/assets/banner.png +0 -0
- package/batch_runner.py +1303 -0
- package/bin/calvyn.js +67 -0
- package/calvyn_bootstrap.py +130 -0
- package/calvyn_constants.py +346 -0
- package/calvyn_logging.py +390 -0
- package/calvyn_state.py +2967 -0
- package/calvyn_time.py +105 -0
- package/cli.py +14160 -0
- package/cron/__init__.py +42 -0
- package/cron/__pycache__/__init__.cpython-312.pyc +0 -0
- package/cron/__pycache__/jobs.cpython-312.pyc +0 -0
- package/cron/__pycache__/scheduler.cpython-312.pyc +0 -0
- package/cron/jobs.py +1160 -0
- package/cron/scheduler.py +1832 -0
- package/gateway/__init__.py +35 -0
- package/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
- package/gateway/__pycache__/channel_directory.cpython-312.pyc +0 -0
- package/gateway/__pycache__/config.cpython-312.pyc +0 -0
- package/gateway/__pycache__/delivery.cpython-312.pyc +0 -0
- package/gateway/__pycache__/display_config.cpython-312.pyc +0 -0
- package/gateway/__pycache__/hooks.cpython-312.pyc +0 -0
- package/gateway/__pycache__/pairing.cpython-312.pyc +0 -0
- package/gateway/__pycache__/platform_registry.cpython-312.pyc +0 -0
- package/gateway/__pycache__/restart.cpython-312.pyc +0 -0
- package/gateway/__pycache__/run.cpython-312.pyc +0 -0
- package/gateway/__pycache__/runtime_footer.cpython-312.pyc +0 -0
- package/gateway/__pycache__/session.cpython-312.pyc +0 -0
- package/gateway/__pycache__/session_context.cpython-312.pyc +0 -0
- package/gateway/__pycache__/shutdown_forensics.cpython-312.pyc +0 -0
- package/gateway/__pycache__/slash_access.cpython-312.pyc +0 -0
- package/gateway/__pycache__/status.cpython-312.pyc +0 -0
- package/gateway/__pycache__/stream_consumer.cpython-312.pyc +0 -0
- package/gateway/__pycache__/whatsapp_identity.cpython-312.pyc +0 -0
- package/gateway/assets/telegram-botfather-threads-settings.jpg +0 -0
- package/gateway/builtin_hooks/__init__.py +1 -0
- package/gateway/channel_directory.py +357 -0
- package/gateway/config.py +1873 -0
- package/gateway/delivery.py +258 -0
- package/gateway/display_config.py +206 -0
- package/gateway/hooks.py +210 -0
- package/gateway/mirror.py +179 -0
- package/gateway/pairing.py +322 -0
- package/gateway/platform_registry.py +260 -0
- package/gateway/platforms/ADDING_A_PLATFORM.md +374 -0
- package/gateway/platforms/__init__.py +45 -0
- package/gateway/platforms/__pycache__/__init__.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/base.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/helpers.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/telegram.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/telegram_network.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/yuanbao.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/yuanbao_media.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/yuanbao_proto.cpython-312.pyc +0 -0
- package/gateway/platforms/_http_client_limits.py +84 -0
- package/gateway/platforms/api_server.py +3488 -0
- package/gateway/platforms/base.py +3747 -0
- package/gateway/platforms/bluebubbles.py +937 -0
- package/gateway/platforms/dingtalk.py +1473 -0
- package/gateway/platforms/discord.py +5584 -0
- package/gateway/platforms/email.py +773 -0
- package/gateway/platforms/feishu.py +5059 -0
- package/gateway/platforms/feishu_comment.py +1382 -0
- package/gateway/platforms/feishu_comment_rules.py +430 -0
- package/gateway/platforms/helpers.py +279 -0
- package/gateway/platforms/homeassistant.py +449 -0
- package/gateway/platforms/matrix.py +2777 -0
- package/gateway/platforms/mattermost.py +852 -0
- package/gateway/platforms/msgraph_webhook.py +397 -0
- package/gateway/platforms/qqbot/__init__.py +91 -0
- package/gateway/platforms/qqbot/adapter.py +3072 -0
- package/gateway/platforms/qqbot/chunked_upload.py +602 -0
- package/gateway/platforms/qqbot/constants.py +74 -0
- package/gateway/platforms/qqbot/crypto.py +45 -0
- package/gateway/platforms/qqbot/keyboards.py +473 -0
- package/gateway/platforms/qqbot/onboard.py +220 -0
- package/gateway/platforms/qqbot/utils.py +71 -0
- package/gateway/platforms/signal.py +1518 -0
- package/gateway/platforms/signal_rate_limit.py +369 -0
- package/gateway/platforms/slack.py +3028 -0
- package/gateway/platforms/sms.py +377 -0
- package/gateway/platforms/telegram.py +4836 -0
- package/gateway/platforms/telegram_network.py +249 -0
- package/gateway/platforms/webhook.py +806 -0
- package/gateway/platforms/wecom.py +1610 -0
- package/gateway/platforms/wecom_callback.py +403 -0
- package/gateway/platforms/wecom_crypto.py +142 -0
- package/gateway/platforms/weixin.py +2170 -0
- package/gateway/platforms/whatsapp.py +1283 -0
- package/gateway/platforms/yuanbao.py +4873 -0
- package/gateway/platforms/yuanbao_media.py +645 -0
- package/gateway/platforms/yuanbao_proto.py +1209 -0
- package/gateway/platforms/yuanbao_sticker.py +558 -0
- package/gateway/restart.py +20 -0
- package/gateway/run.py +17074 -0
- package/gateway/runtime_footer.py +150 -0
- package/gateway/session.py +1399 -0
- package/gateway/session_context.py +156 -0
- package/gateway/shutdown_forensics.py +462 -0
- package/gateway/slash_access.py +229 -0
- package/gateway/status.py +972 -0
- package/gateway/sticker_cache.py +111 -0
- package/gateway/stream_consumer.py +1286 -0
- package/gateway/whatsapp_identity.py +156 -0
- package/hermes_cli/__init__.py +47 -0
- package/hermes_cli/__pycache__/__init__.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/_parser.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/auth.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/banner.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/browser_connect.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/callbacks.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/checkpoints.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/cli_output.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/codex_models.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/codex_runtime_switch.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/colors.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/commands.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/config.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/copilot_auth.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/curator.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/curses_ui.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/debug.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/default_soul.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/env_loader.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/fallback_cmd.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/gateway.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/gateway_windows.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/goals.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/inventory.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/kanban.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/kanban_db.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/main.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/model_normalize.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/model_switch.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/models.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/nous_subscription.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/pairing.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/platforms.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/plugins.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/profiles.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/providers.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/pt_input_extras.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/runtime_provider.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/security_advisories.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/setup.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/skills_hub.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/skin_engine.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/stdio.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/timeouts.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/tips.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/tools_config.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/voice.cpython-312.pyc +0 -0
- package/hermes_cli/_parser.py +365 -0
- package/hermes_cli/_subprocess_compat.py +175 -0
- package/hermes_cli/auth.py +6299 -0
- package/hermes_cli/auth_commands.py +749 -0
- package/hermes_cli/azure_detect.py +300 -0
- package/hermes_cli/backup.py +938 -0
- package/hermes_cli/banner.py +703 -0
- package/hermes_cli/browser_connect.py +139 -0
- package/hermes_cli/callbacks.py +243 -0
- package/hermes_cli/checkpoints.py +244 -0
- package/hermes_cli/claw.py +810 -0
- package/hermes_cli/cli_output.py +78 -0
- package/hermes_cli/clipboard.py +495 -0
- package/hermes_cli/codex_models.py +198 -0
- package/hermes_cli/codex_runtime_plugin_migration.py +757 -0
- package/hermes_cli/codex_runtime_switch.py +266 -0
- package/hermes_cli/colors.py +38 -0
- package/hermes_cli/commands.py +1728 -0
- package/hermes_cli/completion.py +315 -0
- package/hermes_cli/config.py +5382 -0
- package/hermes_cli/copilot_auth.py +392 -0
- package/hermes_cli/cron.py +313 -0
- package/hermes_cli/curator.py +598 -0
- package/hermes_cli/curses_ui.py +472 -0
- package/hermes_cli/debug.py +747 -0
- package/hermes_cli/default_soul.py +11 -0
- package/hermes_cli/dep_ensure.py +107 -0
- package/hermes_cli/dingtalk_auth.py +293 -0
- package/hermes_cli/doctor.py +1863 -0
- package/hermes_cli/dump.py +326 -0
- package/hermes_cli/env_loader.py +175 -0
- package/hermes_cli/fallback_cmd.py +361 -0
- package/hermes_cli/gateway.py +5422 -0
- package/hermes_cli/gateway_windows.py +692 -0
- package/hermes_cli/goals.py +757 -0
- package/hermes_cli/hooks.py +385 -0
- package/hermes_cli/inventory.py +240 -0
- package/hermes_cli/kanban.py +2252 -0
- package/hermes_cli/kanban_db.py +4840 -0
- package/hermes_cli/kanban_diagnostics.py +776 -0
- package/hermes_cli/kanban_specify.py +266 -0
- package/hermes_cli/logs.py +391 -0
- package/hermes_cli/main.py +12396 -0
- package/hermes_cli/mcp_config.py +781 -0
- package/hermes_cli/memory_setup.py +465 -0
- package/hermes_cli/model_catalog.py +330 -0
- package/hermes_cli/model_normalize.py +473 -0
- package/hermes_cli/model_switch.py +1777 -0
- package/hermes_cli/models.py +3789 -0
- package/hermes_cli/nous_subscription.py +799 -0
- package/hermes_cli/oneshot.py +351 -0
- package/hermes_cli/pairing.py +115 -0
- package/hermes_cli/platforms.py +83 -0
- package/hermes_cli/plugins.py +1562 -0
- package/hermes_cli/plugins_cmd.py +1587 -0
- package/hermes_cli/profile_distribution.py +703 -0
- package/hermes_cli/profiles.py +1319 -0
- package/hermes_cli/providers.py +720 -0
- package/hermes_cli/proxy/__init__.py +20 -0
- package/hermes_cli/proxy/adapters/__init__.py +35 -0
- package/hermes_cli/proxy/adapters/base.py +94 -0
- package/hermes_cli/proxy/adapters/nous_portal.py +137 -0
- package/hermes_cli/proxy/cli.py +141 -0
- package/hermes_cli/proxy/server.py +265 -0
- package/hermes_cli/pt_input_extras.py +83 -0
- package/hermes_cli/pty_bridge.py +237 -0
- package/hermes_cli/relaunch.py +205 -0
- package/hermes_cli/runtime_provider.py +1428 -0
- package/hermes_cli/security_advisories.py +452 -0
- package/hermes_cli/setup.py +3559 -0
- package/hermes_cli/skills_config.py +177 -0
- package/hermes_cli/skills_hub.py +1595 -0
- package/hermes_cli/skin_engine.py +929 -0
- package/hermes_cli/slack_cli.py +160 -0
- package/hermes_cli/status.py +550 -0
- package/hermes_cli/stdio.py +252 -0
- package/hermes_cli/timeouts.py +82 -0
- package/hermes_cli/tips.py +487 -0
- package/hermes_cli/tools_config.py +3151 -0
- package/hermes_cli/uninstall.py +681 -0
- package/hermes_cli/vercel_auth.py +70 -0
- package/hermes_cli/voice.py +846 -0
- package/hermes_cli/web_server.py +4438 -0
- package/hermes_cli/webhook.py +275 -0
- package/locales/af.yaml +350 -0
- package/locales/de.yaml +350 -0
- package/locales/en.yaml +365 -0
- package/locales/es.yaml +350 -0
- package/locales/fr.yaml +350 -0
- package/locales/ga.yaml +354 -0
- package/locales/hu.yaml +350 -0
- package/locales/it.yaml +350 -0
- package/locales/ja.yaml +350 -0
- package/locales/ko.yaml +350 -0
- package/locales/pt.yaml +350 -0
- package/locales/ru.yaml +350 -0
- package/locales/tr.yaml +350 -0
- package/locales/uk.yaml +350 -0
- package/locales/zh-hant.yaml +350 -0
- package/locales/zh.yaml +350 -0
- package/mcp_serve.py +898 -0
- package/model_tools.py +899 -0
- package/optional-skills/DESCRIPTION.md +24 -0
- package/optional-skills/autonomous-ai-agents/DESCRIPTION.md +2 -0
- package/optional-skills/autonomous-ai-agents/blackbox/SKILL.md +144 -0
- package/optional-skills/autonomous-ai-agents/honcho/SKILL.md +431 -0
- package/optional-skills/blockchain/evm/SKILL.md +211 -0
- package/optional-skills/blockchain/evm/scripts/evm_client.py +1508 -0
- package/optional-skills/blockchain/hyperliquid/SKILL.md +211 -0
- package/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1660 -0
- package/optional-skills/blockchain/solana/SKILL.md +208 -0
- package/optional-skills/blockchain/solana/scripts/solana_client.py +698 -0
- package/optional-skills/communication/DESCRIPTION.md +1 -0
- package/optional-skills/communication/one-three-one-rule/SKILL.md +104 -0
- package/optional-skills/creative/blender-mcp/SKILL.md +117 -0
- package/optional-skills/creative/concept-diagrams/SKILL.md +362 -0
- package/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md +244 -0
- package/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md +276 -0
- package/optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md +240 -0
- package/optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md +161 -0
- package/optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md +209 -0
- package/optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md +236 -0
- package/optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md +182 -0
- package/optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md +172 -0
- package/optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md +165 -0
- package/optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md +114 -0
- package/optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md +325 -0
- package/optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md +173 -0
- package/optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md +154 -0
- package/optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md +247 -0
- package/optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md +338 -0
- package/optional-skills/creative/concept-diagrams/references/dashboard-patterns.md +43 -0
- package/optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md +144 -0
- package/optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md +42 -0
- package/optional-skills/creative/concept-diagrams/templates/template.html +174 -0
- package/optional-skills/creative/hyperframes/SKILL.md +191 -0
- package/optional-skills/creative/hyperframes/references/cli.md +185 -0
- package/optional-skills/creative/hyperframes/references/composition.md +129 -0
- package/optional-skills/creative/hyperframes/references/features.md +289 -0
- package/optional-skills/creative/hyperframes/references/gsap.md +136 -0
- package/optional-skills/creative/hyperframes/references/troubleshooting.md +137 -0
- package/optional-skills/creative/hyperframes/references/website-to-video.md +145 -0
- package/optional-skills/creative/hyperframes/scripts/setup.sh +135 -0
- package/optional-skills/creative/kanban-video-orchestrator/SKILL.md +207 -0
- package/optional-skills/creative/kanban-video-orchestrator/assets/brief.md.tmpl +79 -0
- package/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +185 -0
- package/optional-skills/creative/kanban-video-orchestrator/assets/soul.md.tmpl +38 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/examples.md +227 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/intake.md +166 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +276 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/monitoring.md +180 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md +298 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +317 -0
- package/optional-skills/creative/kanban-video-orchestrator/scripts/bootstrap_pipeline.py +501 -0
- package/optional-skills/creative/kanban-video-orchestrator/scripts/monitor.py +195 -0
- package/optional-skills/creative/meme-generation/EXAMPLES.md +46 -0
- package/optional-skills/creative/meme-generation/SKILL.md +130 -0
- package/optional-skills/creative/meme-generation/scripts/generate_meme.py +471 -0
- package/optional-skills/creative/meme-generation/scripts/templates.json +97 -0
- package/optional-skills/devops/cli/SKILL.md +156 -0
- package/optional-skills/devops/cli/references/app-discovery.md +112 -0
- package/optional-skills/devops/cli/references/authentication.md +59 -0
- package/optional-skills/devops/cli/references/cli-reference.md +104 -0
- package/optional-skills/devops/cli/references/running-apps.md +171 -0
- package/optional-skills/devops/docker-management/SKILL.md +281 -0
- package/optional-skills/devops/pinggy-tunnel/SKILL.md +309 -0
- package/optional-skills/devops/watchers/SKILL.md +112 -0
- package/optional-skills/devops/watchers/scripts/_watermark.py +148 -0
- package/optional-skills/devops/watchers/scripts/watch_github.py +168 -0
- package/optional-skills/devops/watchers/scripts/watch_http_json.py +131 -0
- package/optional-skills/devops/watchers/scripts/watch_rss.py +121 -0
- package/optional-skills/dogfood/DESCRIPTION.md +3 -0
- package/optional-skills/dogfood/adversarial-ux-test/SKILL.md +191 -0
- package/optional-skills/email/agentmail/SKILL.md +126 -0
- package/optional-skills/finance/3-statement-model/SKILL.md +433 -0
- package/optional-skills/finance/3-statement-model/references/formatting.md +118 -0
- package/optional-skills/finance/3-statement-model/references/formulas.md +292 -0
- package/optional-skills/finance/3-statement-model/references/sec-filings.md +125 -0
- package/optional-skills/finance/comps-analysis/SKILL.md +662 -0
- package/optional-skills/finance/dcf-model/SKILL.md +1270 -0
- package/optional-skills/finance/dcf-model/TROUBLESHOOTING.md +40 -0
- package/optional-skills/finance/dcf-model/requirements.txt +7 -0
- package/optional-skills/finance/dcf-model/scripts/validate_dcf.py +292 -0
- package/optional-skills/finance/excel-author/SKILL.md +244 -0
- package/optional-skills/finance/excel-author/scripts/recalc.py +88 -0
- package/optional-skills/finance/lbo-model/SKILL.md +291 -0
- package/optional-skills/finance/merger-model/SKILL.md +144 -0
- package/optional-skills/finance/pptx-author/SKILL.md +173 -0
- package/optional-skills/finance/stocks/SKILL.md +95 -0
- package/optional-skills/finance/stocks/scripts/stocks_client.py +755 -0
- package/optional-skills/health/DESCRIPTION.md +1 -0
- package/optional-skills/health/fitness-nutrition/SKILL.md +256 -0
- package/optional-skills/health/fitness-nutrition/references/FORMULAS.md +100 -0
- package/optional-skills/health/fitness-nutrition/scripts/body_calc.py +210 -0
- package/optional-skills/health/fitness-nutrition/scripts/nutrition_search.py +86 -0
- package/optional-skills/health/neuroskill-bci/SKILL.md +459 -0
- package/optional-skills/health/neuroskill-bci/references/api.md +286 -0
- package/optional-skills/health/neuroskill-bci/references/metrics.md +220 -0
- package/optional-skills/health/neuroskill-bci/references/protocols.md +452 -0
- package/optional-skills/mcp/DESCRIPTION.md +3 -0
- package/optional-skills/mcp/fastmcp/SKILL.md +300 -0
- package/optional-skills/mcp/fastmcp/references/fastmcp-cli.md +110 -0
- package/optional-skills/mcp/fastmcp/scripts/scaffold_fastmcp.py +56 -0
- package/optional-skills/mcp/fastmcp/templates/api_wrapper.py +54 -0
- package/optional-skills/mcp/fastmcp/templates/database_server.py +77 -0
- package/optional-skills/mcp/fastmcp/templates/file_processor.py +55 -0
- package/optional-skills/mcp/mcporter/SKILL.md +123 -0
- package/optional-skills/migration/DESCRIPTION.md +2 -0
- package/optional-skills/migration/openclaw-migration/SKILL.md +298 -0
- package/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +3136 -0
- package/optional-skills/mlops/accelerate/SKILL.md +336 -0
- package/optional-skills/mlops/accelerate/references/custom-plugins.md +453 -0
- package/optional-skills/mlops/accelerate/references/megatron-integration.md +489 -0
- package/optional-skills/mlops/accelerate/references/performance.md +525 -0
- package/optional-skills/mlops/chroma/SKILL.md +410 -0
- package/optional-skills/mlops/chroma/references/integration.md +38 -0
- package/optional-skills/mlops/clip/SKILL.md +257 -0
- package/optional-skills/mlops/clip/references/applications.md +207 -0
- package/optional-skills/mlops/faiss/SKILL.md +225 -0
- package/optional-skills/mlops/faiss/references/index_types.md +280 -0
- package/optional-skills/mlops/flash-attention/SKILL.md +367 -0
- package/optional-skills/mlops/flash-attention/references/benchmarks.md +215 -0
- package/optional-skills/mlops/flash-attention/references/transformers-integration.md +293 -0
- package/optional-skills/mlops/guidance/SKILL.md +576 -0
- package/optional-skills/mlops/guidance/references/backends.md +554 -0
- package/optional-skills/mlops/guidance/references/constraints.md +674 -0
- package/optional-skills/mlops/guidance/references/examples.md +767 -0
- package/optional-skills/mlops/huggingface-tokenizers/SKILL.md +520 -0
- package/optional-skills/mlops/huggingface-tokenizers/references/algorithms.md +653 -0
- package/optional-skills/mlops/huggingface-tokenizers/references/integration.md +637 -0
- package/optional-skills/mlops/huggingface-tokenizers/references/pipeline.md +723 -0
- package/optional-skills/mlops/huggingface-tokenizers/references/training.md +565 -0
- package/optional-skills/mlops/inference/outlines/SKILL.md +656 -0
- package/optional-skills/mlops/inference/outlines/references/backends.md +615 -0
- package/optional-skills/mlops/inference/outlines/references/examples.md +773 -0
- package/optional-skills/mlops/inference/outlines/references/json_generation.md +652 -0
- package/optional-skills/mlops/instructor/SKILL.md +744 -0
- package/optional-skills/mlops/instructor/references/examples.md +107 -0
- package/optional-skills/mlops/instructor/references/providers.md +70 -0
- package/optional-skills/mlops/instructor/references/validation.md +606 -0
- package/optional-skills/mlops/lambda-labs/SKILL.md +549 -0
- package/optional-skills/mlops/lambda-labs/references/advanced-usage.md +611 -0
- package/optional-skills/mlops/lambda-labs/references/troubleshooting.md +530 -0
- package/optional-skills/mlops/llava/SKILL.md +308 -0
- package/optional-skills/mlops/llava/references/training.md +197 -0
- package/optional-skills/mlops/modal/SKILL.md +345 -0
- package/optional-skills/mlops/modal/references/advanced-usage.md +503 -0
- package/optional-skills/mlops/modal/references/troubleshooting.md +494 -0
- package/optional-skills/mlops/nemo-curator/SKILL.md +387 -0
- package/optional-skills/mlops/nemo-curator/references/deduplication.md +87 -0
- package/optional-skills/mlops/nemo-curator/references/filtering.md +102 -0
- package/optional-skills/mlops/peft/SKILL.md +435 -0
- package/optional-skills/mlops/peft/references/advanced-usage.md +514 -0
- package/optional-skills/mlops/peft/references/troubleshooting.md +480 -0
- package/optional-skills/mlops/pinecone/SKILL.md +362 -0
- package/optional-skills/mlops/pinecone/references/deployment.md +181 -0
- package/optional-skills/mlops/pytorch-fsdp/SKILL.md +130 -0
- package/optional-skills/mlops/pytorch-fsdp/references/index.md +7 -0
- package/optional-skills/mlops/pytorch-fsdp/references/other.md +4261 -0
- package/optional-skills/mlops/pytorch-lightning/SKILL.md +350 -0
- package/optional-skills/mlops/pytorch-lightning/references/callbacks.md +436 -0
- package/optional-skills/mlops/pytorch-lightning/references/distributed.md +490 -0
- package/optional-skills/mlops/pytorch-lightning/references/hyperparameter-tuning.md +556 -0
- package/optional-skills/mlops/qdrant/SKILL.md +497 -0
- package/optional-skills/mlops/qdrant/references/advanced-usage.md +648 -0
- package/optional-skills/mlops/qdrant/references/troubleshooting.md +631 -0
- package/optional-skills/mlops/saelens/SKILL.md +390 -0
- package/optional-skills/mlops/saelens/references/README.md +69 -0
- package/optional-skills/mlops/saelens/references/api.md +333 -0
- package/optional-skills/mlops/saelens/references/tutorials.md +318 -0
- package/optional-skills/mlops/simpo/SKILL.md +223 -0
- package/optional-skills/mlops/simpo/references/datasets.md +478 -0
- package/optional-skills/mlops/simpo/references/hyperparameters.md +452 -0
- package/optional-skills/mlops/simpo/references/loss-functions.md +350 -0
- package/optional-skills/mlops/slime/SKILL.md +468 -0
- package/optional-skills/mlops/slime/references/api-reference.md +392 -0
- package/optional-skills/mlops/slime/references/troubleshooting.md +386 -0
- package/optional-skills/mlops/stable-diffusion/SKILL.md +523 -0
- package/optional-skills/mlops/stable-diffusion/references/advanced-usage.md +716 -0
- package/optional-skills/mlops/stable-diffusion/references/troubleshooting.md +555 -0
- package/optional-skills/mlops/tensorrt-llm/SKILL.md +191 -0
- package/optional-skills/mlops/tensorrt-llm/references/multi-gpu.md +298 -0
- package/optional-skills/mlops/tensorrt-llm/references/optimization.md +242 -0
- package/optional-skills/mlops/tensorrt-llm/references/serving.md +470 -0
- package/optional-skills/mlops/torchtitan/SKILL.md +362 -0
- package/optional-skills/mlops/torchtitan/references/checkpoint.md +181 -0
- package/optional-skills/mlops/torchtitan/references/custom-models.md +258 -0
- package/optional-skills/mlops/torchtitan/references/float8.md +133 -0
- package/optional-skills/mlops/torchtitan/references/fsdp.md +126 -0
- package/optional-skills/mlops/training/axolotl/SKILL.md +166 -0
- package/optional-skills/mlops/training/axolotl/references/api.md +5548 -0
- package/optional-skills/mlops/training/axolotl/references/dataset-formats.md +1029 -0
- package/optional-skills/mlops/training/axolotl/references/index.md +15 -0
- package/optional-skills/mlops/training/axolotl/references/other.md +3563 -0
- package/optional-skills/mlops/training/trl-fine-tuning/SKILL.md +463 -0
- package/optional-skills/mlops/training/trl-fine-tuning/references/dpo-variants.md +227 -0
- package/optional-skills/mlops/training/trl-fine-tuning/references/grpo-training.md +504 -0
- package/optional-skills/mlops/training/trl-fine-tuning/references/online-rl.md +82 -0
- package/optional-skills/mlops/training/trl-fine-tuning/references/reward-modeling.md +122 -0
- package/optional-skills/mlops/training/trl-fine-tuning/references/sft-training.md +168 -0
- package/optional-skills/mlops/training/trl-fine-tuning/templates/basic_grpo_training.py +228 -0
- package/optional-skills/mlops/training/unsloth/SKILL.md +84 -0
- package/optional-skills/mlops/training/unsloth/references/index.md +7 -0
- package/optional-skills/mlops/training/unsloth/references/llms-full.md +16799 -0
- package/optional-skills/mlops/training/unsloth/references/llms-txt.md +12044 -0
- package/optional-skills/mlops/training/unsloth/references/llms.md +82 -0
- package/optional-skills/mlops/whisper/SKILL.md +321 -0
- package/optional-skills/mlops/whisper/references/languages.md +189 -0
- package/optional-skills/productivity/canvas/SKILL.md +98 -0
- package/optional-skills/productivity/canvas/scripts/canvas_api.py +157 -0
- package/optional-skills/productivity/here-now/SKILL.md +217 -0
- package/optional-skills/productivity/here-now/scripts/drive.sh +406 -0
- package/optional-skills/productivity/here-now/scripts/publish.sh +445 -0
- package/optional-skills/productivity/memento-flashcards/SKILL.md +324 -0
- package/optional-skills/productivity/memento-flashcards/scripts/memento_cards.py +353 -0
- package/optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py +88 -0
- package/optional-skills/productivity/shop-app/SKILL.md +340 -0
- package/optional-skills/productivity/shopify/SKILL.md +373 -0
- package/optional-skills/productivity/siyuan/SKILL.md +298 -0
- package/optional-skills/productivity/telephony/SKILL.md +418 -0
- package/optional-skills/productivity/telephony/scripts/telephony.py +1343 -0
- package/optional-skills/research/bioinformatics/SKILL.md +235 -0
- package/optional-skills/research/darwinian-evolver/SKILL.md +199 -0
- package/optional-skills/research/darwinian-evolver/scripts/parrot_openrouter.py +218 -0
- package/optional-skills/research/darwinian-evolver/scripts/show_snapshot.py +69 -0
- package/optional-skills/research/darwinian-evolver/templates/custom_problem_template.py +240 -0
- package/optional-skills/research/domain-intel/SKILL.md +97 -0
- package/optional-skills/research/domain-intel/scripts/domain_intel.py +397 -0
- package/optional-skills/research/drug-discovery/SKILL.md +227 -0
- package/optional-skills/research/drug-discovery/references/ADMET_REFERENCE.md +66 -0
- package/optional-skills/research/drug-discovery/scripts/chembl_target.py +53 -0
- package/optional-skills/research/drug-discovery/scripts/ro5_screen.py +44 -0
- package/optional-skills/research/duckduckgo-search/SKILL.md +238 -0
- package/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh +28 -0
- package/optional-skills/research/gitnexus-explorer/SKILL.md +214 -0
- package/optional-skills/research/gitnexus-explorer/scripts/proxy.mjs +92 -0
- package/optional-skills/research/osint-investigation/SKILL.md +277 -0
- package/optional-skills/research/osint-investigation/references/sources/courtlistener.md +98 -0
- package/optional-skills/research/osint-investigation/references/sources/gdelt.md +104 -0
- package/optional-skills/research/osint-investigation/references/sources/icij-offshore.md +104 -0
- package/optional-skills/research/osint-investigation/references/sources/nyc-acris.md +90 -0
- package/optional-skills/research/osint-investigation/references/sources/ofac-sdn.md +92 -0
- package/optional-skills/research/osint-investigation/references/sources/opencorporates.md +103 -0
- package/optional-skills/research/osint-investigation/references/sources/sec-edgar.md +83 -0
- package/optional-skills/research/osint-investigation/references/sources/senate-ld.md +89 -0
- package/optional-skills/research/osint-investigation/references/sources/usaspending.md +97 -0
- package/optional-skills/research/osint-investigation/references/sources/wayback.md +93 -0
- package/optional-skills/research/osint-investigation/references/sources/wikipedia.md +107 -0
- package/optional-skills/research/osint-investigation/scripts/_http.py +82 -0
- package/optional-skills/research/osint-investigation/scripts/_normalize.py +67 -0
- package/optional-skills/research/osint-investigation/scripts/build_findings.py +221 -0
- package/optional-skills/research/osint-investigation/scripts/entity_resolution.py +228 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_courtlistener.py +149 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_gdelt.py +162 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_icij_offshore.py +234 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_nyc_acris.py +203 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_ofac_sdn.py +175 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_opencorporates.py +192 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_sec_edgar.py +184 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_senate_ld.py +146 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_usaspending.py +170 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_wayback.py +142 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_wikipedia.py +267 -0
- package/optional-skills/research/osint-investigation/scripts/timing_analysis.py +253 -0
- package/optional-skills/research/osint-investigation/templates/source-template.md +59 -0
- package/optional-skills/research/parallel-cli/SKILL.md +391 -0
- package/optional-skills/research/qmd/SKILL.md +441 -0
- package/optional-skills/research/scrapling/SKILL.md +336 -0
- package/optional-skills/research/searxng-search/SKILL.md +212 -0
- package/optional-skills/research/searxng-search/scripts/searxng.sh +22 -0
- package/optional-skills/security/1password/SKILL.md +163 -0
- package/optional-skills/security/1password/references/cli-examples.md +31 -0
- package/optional-skills/security/1password/references/get-started.md +21 -0
- package/optional-skills/security/DESCRIPTION.md +3 -0
- package/optional-skills/security/oss-forensics/SKILL.md +423 -0
- package/optional-skills/security/oss-forensics/references/evidence-types.md +89 -0
- package/optional-skills/security/oss-forensics/references/github-archive-guide.md +184 -0
- package/optional-skills/security/oss-forensics/references/investigation-templates.md +131 -0
- package/optional-skills/security/oss-forensics/references/recovery-techniques.md +164 -0
- package/optional-skills/security/oss-forensics/scripts/evidence-store.py +313 -0
- package/optional-skills/security/oss-forensics/templates/forensic-report.md +151 -0
- package/optional-skills/security/oss-forensics/templates/malicious-package-report.md +43 -0
- package/optional-skills/security/sherlock/SKILL.md +193 -0
- package/optional-skills/software-development/rest-graphql-debug/SKILL.md +514 -0
- package/optional-skills/web-development/DESCRIPTION.md +5 -0
- package/optional-skills/web-development/page-agent/SKILL.md +190 -0
- package/package.json +78 -0
- package/plugins/__init__.py +1 -0
- package/plugins/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/context_engine/__init__.py +219 -0
- package/plugins/disk-cleanup/README.md +51 -0
- package/plugins/disk-cleanup/__init__.py +316 -0
- package/plugins/disk-cleanup/disk_cleanup.py +497 -0
- package/plugins/disk-cleanup/plugin.yaml +7 -0
- package/plugins/example-dashboard/dashboard/manifest.json +14 -0
- package/plugins/example-dashboard/dashboard/plugin_api.py +17 -0
- package/plugins/google_meet/README.md +131 -0
- package/plugins/google_meet/SKILL.md +148 -0
- package/plugins/google_meet/__init__.py +103 -0
- package/plugins/google_meet/audio_bridge.py +244 -0
- package/plugins/google_meet/cli.py +479 -0
- package/plugins/google_meet/meet_bot.py +852 -0
- package/plugins/google_meet/node/__init__.py +54 -0
- package/plugins/google_meet/node/cli.py +125 -0
- package/plugins/google_meet/node/client.py +107 -0
- package/plugins/google_meet/node/protocol.py +124 -0
- package/plugins/google_meet/node/registry.py +113 -0
- package/plugins/google_meet/node/server.py +201 -0
- package/plugins/google_meet/plugin.yaml +16 -0
- package/plugins/google_meet/process_manager.py +324 -0
- package/plugins/google_meet/realtime/__init__.py +10 -0
- package/plugins/google_meet/realtime/openai_client.py +332 -0
- package/plugins/google_meet/tools.py +348 -0
- package/plugins/hermes-achievements/LICENSE +21 -0
- package/plugins/hermes-achievements/README.md +150 -0
- package/plugins/hermes-achievements/dashboard/dist/index.js +732 -0
- package/plugins/hermes-achievements/dashboard/dist/style.css +146 -0
- package/plugins/hermes-achievements/dashboard/manifest.json +11 -0
- package/plugins/hermes-achievements/dashboard/plugin_api.py +1062 -0
- package/plugins/hermes-achievements/docs/achievements-performance-implementation-plan.md +157 -0
- package/plugins/hermes-achievements/docs/achievements-performance-implementation-spec.md +219 -0
- package/plugins/hermes-achievements/docs/achievements-performance-spec.md +174 -0
- package/plugins/hermes-achievements/docs/assets/achievements-dashboard-hd.png +0 -0
- package/plugins/hermes-achievements/docs/assets/achievements-tier-showcase-hd.png +0 -0
- package/plugins/hermes-achievements/tests/test_achievement_engine.py +156 -0
- package/plugins/image_gen/openai/__init__.py +303 -0
- package/plugins/image_gen/openai/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/image_gen/openai/plugin.yaml +7 -0
- package/plugins/image_gen/openai-codex/__init__.py +378 -0
- package/plugins/image_gen/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/image_gen/openai-codex/plugin.yaml +5 -0
- package/plugins/image_gen/xai/__init__.py +316 -0
- package/plugins/image_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/image_gen/xai/plugin.yaml +7 -0
- package/plugins/kanban/dashboard/dist/index.js +3143 -0
- package/plugins/kanban/dashboard/dist/style.css +1500 -0
- package/plugins/kanban/dashboard/manifest.json +14 -0
- package/plugins/kanban/dashboard/plugin_api.py +1612 -0
- package/plugins/kanban/systemd/hermes-kanban-dispatcher.service +32 -0
- package/plugins/memory/__init__.py +408 -0
- package/plugins/memory/byterover/README.md +41 -0
- package/plugins/memory/byterover/__init__.py +384 -0
- package/plugins/memory/byterover/plugin.yaml +9 -0
- package/plugins/memory/hindsight/README.md +138 -0
- package/plugins/memory/hindsight/__init__.py +1758 -0
- package/plugins/memory/hindsight/plugin.yaml +8 -0
- package/plugins/memory/holographic/README.md +36 -0
- package/plugins/memory/holographic/__init__.py +409 -0
- package/plugins/memory/holographic/holographic.py +203 -0
- package/plugins/memory/holographic/plugin.yaml +5 -0
- package/plugins/memory/holographic/retrieval.py +593 -0
- package/plugins/memory/holographic/store.py +579 -0
- package/plugins/memory/honcho/README.md +328 -0
- package/plugins/memory/honcho/__init__.py +1329 -0
- package/plugins/memory/honcho/cli.py +1452 -0
- package/plugins/memory/honcho/client.py +784 -0
- package/plugins/memory/honcho/plugin.yaml +7 -0
- package/plugins/memory/honcho/session.py +1255 -0
- package/plugins/memory/mem0/README.md +38 -0
- package/plugins/memory/mem0/__init__.py +374 -0
- package/plugins/memory/mem0/plugin.yaml +5 -0
- package/plugins/memory/openviking/README.md +40 -0
- package/plugins/memory/openviking/__init__.py +945 -0
- package/plugins/memory/openviking/plugin.yaml +9 -0
- package/plugins/memory/retaindb/README.md +40 -0
- package/plugins/memory/retaindb/__init__.py +767 -0
- package/plugins/memory/retaindb/plugin.yaml +7 -0
- package/plugins/memory/supermemory/README.md +99 -0
- package/plugins/memory/supermemory/__init__.py +792 -0
- package/plugins/memory/supermemory/plugin.yaml +5 -0
- package/plugins/model-providers/README.md +70 -0
- package/plugins/model-providers/ai-gateway/__init__.py +43 -0
- package/plugins/model-providers/ai-gateway/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/ai-gateway/plugin.yaml +5 -0
- package/plugins/model-providers/alibaba/__init__.py +13 -0
- package/plugins/model-providers/alibaba/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/alibaba/plugin.yaml +5 -0
- package/plugins/model-providers/alibaba-coding-plan/__init__.py +21 -0
- package/plugins/model-providers/alibaba-coding-plan/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/alibaba-coding-plan/plugin.yaml +5 -0
- package/plugins/model-providers/anthropic/__init__.py +52 -0
- package/plugins/model-providers/anthropic/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/anthropic/plugin.yaml +5 -0
- package/plugins/model-providers/arcee/__init__.py +13 -0
- package/plugins/model-providers/arcee/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/arcee/plugin.yaml +5 -0
- package/plugins/model-providers/azure-foundry/__init__.py +21 -0
- package/plugins/model-providers/azure-foundry/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/azure-foundry/plugin.yaml +5 -0
- package/plugins/model-providers/bedrock/__init__.py +29 -0
- package/plugins/model-providers/bedrock/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/bedrock/plugin.yaml +5 -0
- package/plugins/model-providers/copilot/__init__.py +58 -0
- package/plugins/model-providers/copilot/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/copilot/plugin.yaml +5 -0
- package/plugins/model-providers/copilot-acp/__init__.py +34 -0
- package/plugins/model-providers/copilot-acp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/copilot-acp/plugin.yaml +5 -0
- package/plugins/model-providers/custom/__init__.py +68 -0
- package/plugins/model-providers/custom/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/custom/plugin.yaml +5 -0
- package/plugins/model-providers/deepseek/__init__.py +99 -0
- package/plugins/model-providers/deepseek/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/deepseek/plugin.yaml +5 -0
- package/plugins/model-providers/gemini/__init__.py +72 -0
- package/plugins/model-providers/gemini/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/gemini/plugin.yaml +5 -0
- package/plugins/model-providers/gmi/__init__.py +31 -0
- package/plugins/model-providers/gmi/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/gmi/plugin.yaml +5 -0
- package/plugins/model-providers/huggingface/__init__.py +20 -0
- package/plugins/model-providers/huggingface/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/huggingface/plugin.yaml +5 -0
- package/plugins/model-providers/kilocode/__init__.py +14 -0
- package/plugins/model-providers/kilocode/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/kilocode/plugin.yaml +5 -0
- package/plugins/model-providers/kimi-coding/__init__.py +71 -0
- package/plugins/model-providers/kimi-coding/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/kimi-coding/plugin.yaml +5 -0
- package/plugins/model-providers/minimax/__init__.py +45 -0
- package/plugins/model-providers/minimax/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/minimax/plugin.yaml +5 -0
- package/plugins/model-providers/nous/__init__.py +54 -0
- package/plugins/model-providers/nous/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/nous/plugin.yaml +5 -0
- package/plugins/model-providers/novita/__init__.py +27 -0
- package/plugins/model-providers/novita/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/novita/plugin.yaml +5 -0
- package/plugins/model-providers/nvidia/__init__.py +21 -0
- package/plugins/model-providers/nvidia/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/nvidia/plugin.yaml +5 -0
- package/plugins/model-providers/ollama-cloud/__init__.py +14 -0
- package/plugins/model-providers/ollama-cloud/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/ollama-cloud/plugin.yaml +5 -0
- package/plugins/model-providers/openai-codex/__init__.py +15 -0
- package/plugins/model-providers/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/openai-codex/plugin.yaml +5 -0
- package/plugins/model-providers/opencode-zen/__init__.py +30 -0
- package/plugins/model-providers/opencode-zen/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/opencode-zen/plugin.yaml +5 -0
- package/plugins/model-providers/openrouter/__init__.py +115 -0
- package/plugins/model-providers/openrouter/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/openrouter/plugin.yaml +5 -0
- package/plugins/model-providers/qwen-oauth/__init__.py +82 -0
- package/plugins/model-providers/qwen-oauth/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/qwen-oauth/plugin.yaml +5 -0
- package/plugins/model-providers/stepfun/__init__.py +14 -0
- package/plugins/model-providers/stepfun/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/stepfun/plugin.yaml +5 -0
- package/plugins/model-providers/xai/__init__.py +15 -0
- package/plugins/model-providers/xai/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/xai/plugin.yaml +5 -0
- package/plugins/model-providers/xiaomi/__init__.py +14 -0
- package/plugins/model-providers/xiaomi/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/xiaomi/plugin.yaml +5 -0
- package/plugins/model-providers/zai/__init__.py +21 -0
- package/plugins/model-providers/zai/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/zai/plugin.yaml +5 -0
- package/plugins/observability/langfuse/README.md +53 -0
- package/plugins/observability/langfuse/__init__.py +1004 -0
- package/plugins/observability/langfuse/plugin.yaml +14 -0
- package/plugins/platforms/google_chat/__init__.py +3 -0
- package/plugins/platforms/google_chat/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/platforms/google_chat/__pycache__/adapter.cpython-312.pyc +0 -0
- package/plugins/platforms/google_chat/adapter.py +3343 -0
- package/plugins/platforms/google_chat/oauth.py +639 -0
- package/plugins/platforms/google_chat/plugin.yaml +39 -0
- package/plugins/platforms/irc/__init__.py +3 -0
- package/plugins/platforms/irc/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/platforms/irc/__pycache__/adapter.cpython-312.pyc +0 -0
- package/plugins/platforms/irc/adapter.py +969 -0
- package/plugins/platforms/irc/plugin.yaml +54 -0
- package/plugins/platforms/line/__init__.py +3 -0
- package/plugins/platforms/line/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/platforms/line/__pycache__/adapter.cpython-312.pyc +0 -0
- package/plugins/platforms/line/adapter.py +1639 -0
- package/plugins/platforms/line/plugin.yaml +65 -0
- package/plugins/platforms/simplex/__init__.py +3 -0
- package/plugins/platforms/simplex/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/platforms/simplex/__pycache__/adapter.cpython-312.pyc +0 -0
- package/plugins/platforms/simplex/adapter.py +746 -0
- package/plugins/platforms/simplex/plugin.yaml +37 -0
- package/plugins/platforms/teams/__init__.py +3 -0
- package/plugins/platforms/teams/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/platforms/teams/__pycache__/adapter.cpython-312.pyc +0 -0
- package/plugins/platforms/teams/adapter.py +1188 -0
- package/plugins/platforms/teams/plugin.yaml +48 -0
- package/plugins/spotify/__init__.py +66 -0
- package/plugins/spotify/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/spotify/__pycache__/client.cpython-312.pyc +0 -0
- package/plugins/spotify/__pycache__/tools.cpython-312.pyc +0 -0
- package/plugins/spotify/client.py +435 -0
- package/plugins/spotify/plugin.yaml +13 -0
- package/plugins/spotify/tools.py +454 -0
- package/plugins/teams_pipeline/__init__.py +23 -0
- package/plugins/teams_pipeline/cli.py +463 -0
- package/plugins/teams_pipeline/meetings.py +333 -0
- package/plugins/teams_pipeline/models.py +350 -0
- package/plugins/teams_pipeline/pipeline.py +692 -0
- package/plugins/teams_pipeline/plugin.yaml +9 -0
- package/plugins/teams_pipeline/runtime.py +135 -0
- package/plugins/teams_pipeline/store.py +194 -0
- package/plugins/teams_pipeline/subscriptions.py +249 -0
- package/plugins/video_gen/fal/__init__.py +523 -0
- package/plugins/video_gen/fal/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/video_gen/fal/plugin.yaml +7 -0
- package/plugins/video_gen/xai/__init__.py +441 -0
- package/plugins/video_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/video_gen/xai/plugin.yaml +7 -0
- package/plugins/web/__init__.py +7 -0
- package/plugins/web/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/brave_free/__init__.py +14 -0
- package/plugins/web/brave_free/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/brave_free/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/brave_free/plugin.yaml +7 -0
- package/plugins/web/brave_free/provider.py +137 -0
- package/plugins/web/ddgs/__init__.py +15 -0
- package/plugins/web/ddgs/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/ddgs/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/ddgs/plugin.yaml +7 -0
- package/plugins/web/ddgs/provider.py +104 -0
- package/plugins/web/exa/__init__.py +15 -0
- package/plugins/web/exa/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/exa/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/exa/plugin.yaml +7 -0
- package/plugins/web/exa/provider.py +212 -0
- package/plugins/web/firecrawl/__init__.py +28 -0
- package/plugins/web/firecrawl/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/firecrawl/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/firecrawl/plugin.yaml +7 -0
- package/plugins/web/firecrawl/provider.py +773 -0
- package/plugins/web/parallel/__init__.py +16 -0
- package/plugins/web/parallel/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/parallel/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/parallel/plugin.yaml +7 -0
- package/plugins/web/parallel/provider.py +291 -0
- package/plugins/web/searxng/__init__.py +15 -0
- package/plugins/web/searxng/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/searxng/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/searxng/plugin.yaml +7 -0
- package/plugins/web/searxng/provider.py +140 -0
- package/plugins/web/tavily/__init__.py +15 -0
- package/plugins/web/tavily/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/tavily/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/tavily/plugin.yaml +7 -0
- package/plugins/web/tavily/provider.py +285 -0
- package/providers/README.md +78 -0
- package/providers/__init__.py +192 -0
- package/providers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/providers/__pycache__/base.cpython-312.pyc +0 -0
- package/providers/base.py +184 -0
- package/pyproject.toml +255 -0
- package/run_agent.py +16409 -0
- package/scripts/benchmark_browser_eval.py +138 -0
- package/scripts/build_model_catalog.py +95 -0
- package/scripts/build_skills_index.py +325 -0
- package/scripts/check-windows-footguns.py +624 -0
- package/scripts/contributor_audit.py +473 -0
- package/scripts/discord-voice-doctor.py +396 -0
- package/scripts/hermes-gateway +416 -0
- package/scripts/install.cmd +28 -0
- package/scripts/install.ps1 +1611 -0
- package/scripts/install.sh +2007 -0
- package/scripts/install_psutil_android.py +117 -0
- package/scripts/keystroke_diagnostic.py +81 -0
- package/scripts/kill_modal.sh +34 -0
- package/scripts/lib/node-bootstrap.sh +238 -0
- package/scripts/lint_diff.py +207 -0
- package/scripts/postinstall.js +150 -0
- package/scripts/profile-tui.py +626 -0
- package/scripts/release.py +1680 -0
- package/scripts/run_tests.sh +129 -0
- package/scripts/sample_and_compress.py +409 -0
- package/scripts/setup_open_webui.sh +349 -0
- package/scripts/whatsapp-bridge/allowlist.js +88 -0
- package/scripts/whatsapp-bridge/allowlist.test.mjs +80 -0
- package/scripts/whatsapp-bridge/bridge.js +729 -0
- package/scripts/whatsapp-bridge/package-lock.json +2141 -0
- package/scripts/whatsapp-bridge/package.json +19 -0
- package/skills/apple/DESCRIPTION.md +2 -0
- package/skills/apple/apple-notes/SKILL.md +90 -0
- package/skills/apple/apple-reminders/SKILL.md +98 -0
- package/skills/apple/findmy/SKILL.md +131 -0
- package/skills/apple/imessage/SKILL.md +102 -0
- package/skills/apple/macos-computer-use/SKILL.md +201 -0
- package/skills/autonomous-ai-agents/DESCRIPTION.md +3 -0
- package/skills/autonomous-ai-agents/claude-code/SKILL.md +745 -0
- package/skills/autonomous-ai-agents/codex/SKILL.md +130 -0
- package/skills/autonomous-ai-agents/hermes-agent/SKILL.md +1014 -0
- package/skills/autonomous-ai-agents/opencode/SKILL.md +219 -0
- package/skills/creative/DESCRIPTION.md +3 -0
- package/skills/creative/architecture-diagram/SKILL.md +148 -0
- package/skills/creative/architecture-diagram/templates/template.html +319 -0
- package/skills/creative/ascii-art/SKILL.md +322 -0
- package/skills/creative/ascii-video/README.md +290 -0
- package/skills/creative/ascii-video/SKILL.md +241 -0
- package/skills/creative/ascii-video/references/architecture.md +802 -0
- package/skills/creative/ascii-video/references/composition.md +892 -0
- package/skills/creative/ascii-video/references/effects.md +1865 -0
- package/skills/creative/ascii-video/references/inputs.md +685 -0
- package/skills/creative/ascii-video/references/optimization.md +688 -0
- package/skills/creative/ascii-video/references/scenes.md +1011 -0
- package/skills/creative/ascii-video/references/shaders.md +1385 -0
- package/skills/creative/ascii-video/references/troubleshooting.md +367 -0
- package/skills/creative/baoyu-comic/PORT_NOTES.md +77 -0
- package/skills/creative/baoyu-comic/SKILL.md +247 -0
- package/skills/creative/baoyu-comic/references/analysis-framework.md +176 -0
- package/skills/creative/baoyu-comic/references/art-styles/chalk.md +101 -0
- package/skills/creative/baoyu-comic/references/art-styles/ink-brush.md +97 -0
- package/skills/creative/baoyu-comic/references/art-styles/ligne-claire.md +75 -0
- package/skills/creative/baoyu-comic/references/art-styles/manga.md +93 -0
- package/skills/creative/baoyu-comic/references/art-styles/minimalist.md +84 -0
- package/skills/creative/baoyu-comic/references/art-styles/realistic.md +89 -0
- package/skills/creative/baoyu-comic/references/auto-selection.md +71 -0
- package/skills/creative/baoyu-comic/references/base-prompt.md +98 -0
- package/skills/creative/baoyu-comic/references/character-template.md +180 -0
- package/skills/creative/baoyu-comic/references/layouts/cinematic.md +23 -0
- package/skills/creative/baoyu-comic/references/layouts/dense.md +23 -0
- package/skills/creative/baoyu-comic/references/layouts/four-panel.md +40 -0
- package/skills/creative/baoyu-comic/references/layouts/mixed.md +23 -0
- package/skills/creative/baoyu-comic/references/layouts/splash.md +23 -0
- package/skills/creative/baoyu-comic/references/layouts/standard.md +23 -0
- package/skills/creative/baoyu-comic/references/layouts/webtoon.md +30 -0
- package/skills/creative/baoyu-comic/references/ohmsha-guide.md +85 -0
- package/skills/creative/baoyu-comic/references/partial-workflows.md +106 -0
- package/skills/creative/baoyu-comic/references/presets/concept-story.md +121 -0
- package/skills/creative/baoyu-comic/references/presets/four-panel.md +107 -0
- package/skills/creative/baoyu-comic/references/presets/ohmsha.md +114 -0
- package/skills/creative/baoyu-comic/references/presets/shoujo.md +116 -0
- package/skills/creative/baoyu-comic/references/presets/wuxia.md +110 -0
- package/skills/creative/baoyu-comic/references/storyboard-template.md +143 -0
- package/skills/creative/baoyu-comic/references/tones/action.md +110 -0
- package/skills/creative/baoyu-comic/references/tones/dramatic.md +95 -0
- package/skills/creative/baoyu-comic/references/tones/energetic.md +105 -0
- package/skills/creative/baoyu-comic/references/tones/neutral.md +63 -0
- package/skills/creative/baoyu-comic/references/tones/romantic.md +100 -0
- package/skills/creative/baoyu-comic/references/tones/vintage.md +104 -0
- package/skills/creative/baoyu-comic/references/tones/warm.md +94 -0
- package/skills/creative/baoyu-comic/references/workflow.md +401 -0
- package/skills/creative/baoyu-infographic/PORT_NOTES.md +43 -0
- package/skills/creative/baoyu-infographic/SKILL.md +237 -0
- package/skills/creative/baoyu-infographic/references/analysis-framework.md +182 -0
- package/skills/creative/baoyu-infographic/references/base-prompt.md +43 -0
- package/skills/creative/baoyu-infographic/references/layouts/bento-grid.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/binary-comparison.md +48 -0
- package/skills/creative/baoyu-infographic/references/layouts/bridge.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/circular-flow.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/comic-strip.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/comparison-matrix.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/dashboard.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/dense-modules.md +72 -0
- package/skills/creative/baoyu-infographic/references/layouts/funnel.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/hierarchical-layers.md +48 -0
- package/skills/creative/baoyu-infographic/references/layouts/hub-spoke.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/iceberg.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/isometric-map.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/jigsaw.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/linear-progression.md +48 -0
- package/skills/creative/baoyu-infographic/references/layouts/periodic-table.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/story-mountain.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/structural-breakdown.md +48 -0
- package/skills/creative/baoyu-infographic/references/layouts/tree-branching.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/venn-diagram.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/winding-roadmap.md +41 -0
- package/skills/creative/baoyu-infographic/references/structured-content-template.md +244 -0
- package/skills/creative/baoyu-infographic/references/styles/aged-academia.md +36 -0
- package/skills/creative/baoyu-infographic/references/styles/bold-graphic.md +36 -0
- package/skills/creative/baoyu-infographic/references/styles/chalkboard.md +61 -0
- package/skills/creative/baoyu-infographic/references/styles/claymation.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/corporate-memphis.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/craft-handmade.md +44 -0
- package/skills/creative/baoyu-infographic/references/styles/cyberpunk-neon.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/hand-drawn-edu.md +63 -0
- package/skills/creative/baoyu-infographic/references/styles/ikea-manual.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/kawaii.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/knolling.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/lego-brick.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/morandi-journal.md +60 -0
- package/skills/creative/baoyu-infographic/references/styles/origami.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/pixel-art.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/pop-laboratory.md +48 -0
- package/skills/creative/baoyu-infographic/references/styles/retro-pop-grid.md +47 -0
- package/skills/creative/baoyu-infographic/references/styles/storybook-watercolor.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/subway-map.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/technical-schematic.md +36 -0
- package/skills/creative/baoyu-infographic/references/styles/ui-wireframe.md +29 -0
- package/skills/creative/claude-design/SKILL.md +591 -0
- package/skills/creative/comfyui/SKILL.md +612 -0
- package/skills/creative/comfyui/references/official-cli.md +255 -0
- package/skills/creative/comfyui/references/rest-api.md +312 -0
- package/skills/creative/comfyui/references/template-integrity.md +243 -0
- package/skills/creative/comfyui/references/workflow-format.md +226 -0
- package/skills/creative/comfyui/scripts/_common.py +835 -0
- package/skills/creative/comfyui/scripts/auto_fix_deps.py +225 -0
- package/skills/creative/comfyui/scripts/check_deps.py +437 -0
- package/skills/creative/comfyui/scripts/comfyui_setup.sh +286 -0
- package/skills/creative/comfyui/scripts/extract_schema.py +315 -0
- package/skills/creative/comfyui/scripts/fetch_logs.py +158 -0
- package/skills/creative/comfyui/scripts/hardware_check.py +497 -0
- package/skills/creative/comfyui/scripts/health_check.py +223 -0
- package/skills/creative/comfyui/scripts/run_batch.py +243 -0
- package/skills/creative/comfyui/scripts/run_workflow.py +796 -0
- package/skills/creative/comfyui/scripts/ws_monitor.py +267 -0
- package/skills/creative/comfyui/tests/README.md +50 -0
- package/skills/creative/comfyui/tests/conftest.py +64 -0
- package/skills/creative/comfyui/tests/pytest.ini +5 -0
- package/skills/creative/comfyui/tests/test_check_deps.py +68 -0
- package/skills/creative/comfyui/tests/test_cloud_integration.py +95 -0
- package/skills/creative/comfyui/tests/test_common.py +447 -0
- package/skills/creative/comfyui/tests/test_extract_schema.py +185 -0
- package/skills/creative/comfyui/tests/test_run_workflow.py +213 -0
- package/skills/creative/comfyui/workflows/README.md +86 -0
- package/skills/creative/comfyui/workflows/animatediff_video.json +64 -0
- package/skills/creative/comfyui/workflows/flux_dev_txt2img.json +78 -0
- package/skills/creative/comfyui/workflows/sd15_txt2img.json +49 -0
- package/skills/creative/comfyui/workflows/sdxl_img2img.json +54 -0
- package/skills/creative/comfyui/workflows/sdxl_inpaint.json +59 -0
- package/skills/creative/comfyui/workflows/sdxl_txt2img.json +49 -0
- package/skills/creative/comfyui/workflows/upscale_4x.json +27 -0
- package/skills/creative/comfyui/workflows/wan_video_t2v.json +69 -0
- package/skills/creative/creative-ideation/SKILL.md +152 -0
- package/skills/creative/creative-ideation/references/full-prompt-library.md +110 -0
- package/skills/creative/design-md/SKILL.md +199 -0
- package/skills/creative/design-md/templates/starter.md +99 -0
- package/skills/creative/excalidraw/SKILL.md +199 -0
- package/skills/creative/excalidraw/references/colors.md +44 -0
- package/skills/creative/excalidraw/references/dark-mode.md +68 -0
- package/skills/creative/excalidraw/references/examples.md +141 -0
- package/skills/creative/excalidraw/scripts/upload.py +133 -0
- package/skills/creative/humanizer/LICENSE +21 -0
- package/skills/creative/humanizer/SKILL.md +578 -0
- package/skills/creative/manim-video/README.md +23 -0
- package/skills/creative/manim-video/SKILL.md +269 -0
- package/skills/creative/manim-video/references/animation-design-thinking.md +161 -0
- package/skills/creative/manim-video/references/animations.md +282 -0
- package/skills/creative/manim-video/references/camera-and-3d.md +135 -0
- package/skills/creative/manim-video/references/decorations.md +202 -0
- package/skills/creative/manim-video/references/equations.md +216 -0
- package/skills/creative/manim-video/references/graphs-and-data.md +163 -0
- package/skills/creative/manim-video/references/mobjects.md +333 -0
- package/skills/creative/manim-video/references/paper-explainer.md +255 -0
- package/skills/creative/manim-video/references/production-quality.md +190 -0
- package/skills/creative/manim-video/references/rendering.md +185 -0
- package/skills/creative/manim-video/references/scene-planning.md +118 -0
- package/skills/creative/manim-video/references/troubleshooting.md +135 -0
- package/skills/creative/manim-video/references/updaters-and-trackers.md +260 -0
- package/skills/creative/manim-video/references/visual-design.md +124 -0
- package/skills/creative/manim-video/scripts/setup.sh +14 -0
- package/skills/creative/p5js/README.md +64 -0
- package/skills/creative/p5js/SKILL.md +556 -0
- package/skills/creative/p5js/references/animation.md +439 -0
- package/skills/creative/p5js/references/color-systems.md +352 -0
- package/skills/creative/p5js/references/core-api.md +410 -0
- package/skills/creative/p5js/references/export-pipeline.md +566 -0
- package/skills/creative/p5js/references/interaction.md +398 -0
- package/skills/creative/p5js/references/shapes-and-geometry.md +300 -0
- package/skills/creative/p5js/references/troubleshooting.md +532 -0
- package/skills/creative/p5js/references/typography.md +302 -0
- package/skills/creative/p5js/references/visual-effects.md +895 -0
- package/skills/creative/p5js/references/webgl-and-3d.md +423 -0
- package/skills/creative/p5js/scripts/export-frames.js +179 -0
- package/skills/creative/p5js/scripts/render.sh +108 -0
- package/skills/creative/p5js/scripts/serve.sh +28 -0
- package/skills/creative/p5js/scripts/setup.sh +87 -0
- package/skills/creative/p5js/templates/viewer.html +395 -0
- package/skills/creative/pixel-art/ATTRIBUTION.md +54 -0
- package/skills/creative/pixel-art/SKILL.md +218 -0
- package/skills/creative/pixel-art/references/palettes.md +49 -0
- package/skills/creative/pixel-art/scripts/__init__.py +0 -0
- package/skills/creative/pixel-art/scripts/palettes.py +167 -0
- package/skills/creative/pixel-art/scripts/pixel_art.py +162 -0
- package/skills/creative/pixel-art/scripts/pixel_art_video.py +345 -0
- package/skills/creative/popular-web-designs/SKILL.md +214 -0
- package/skills/creative/popular-web-designs/templates/airbnb.md +259 -0
- package/skills/creative/popular-web-designs/templates/airtable.md +102 -0
- package/skills/creative/popular-web-designs/templates/apple.md +326 -0
- package/skills/creative/popular-web-designs/templates/bmw.md +193 -0
- package/skills/creative/popular-web-designs/templates/cal.md +272 -0
- package/skills/creative/popular-web-designs/templates/claude.md +325 -0
- package/skills/creative/popular-web-designs/templates/clay.md +317 -0
- package/skills/creative/popular-web-designs/templates/clickhouse.md +294 -0
- package/skills/creative/popular-web-designs/templates/cohere.md +279 -0
- package/skills/creative/popular-web-designs/templates/coinbase.md +142 -0
- package/skills/creative/popular-web-designs/templates/composio.md +320 -0
- package/skills/creative/popular-web-designs/templates/cursor.md +322 -0
- package/skills/creative/popular-web-designs/templates/elevenlabs.md +278 -0
- package/skills/creative/popular-web-designs/templates/expo.md +294 -0
- package/skills/creative/popular-web-designs/templates/figma.md +233 -0
- package/skills/creative/popular-web-designs/templates/framer.md +259 -0
- package/skills/creative/popular-web-designs/templates/hashicorp.md +291 -0
- package/skills/creative/popular-web-designs/templates/ibm.md +345 -0
- package/skills/creative/popular-web-designs/templates/intercom.md +159 -0
- package/skills/creative/popular-web-designs/templates/kraken.md +138 -0
- package/skills/creative/popular-web-designs/templates/linear.app.md +380 -0
- package/skills/creative/popular-web-designs/templates/lovable.md +311 -0
- package/skills/creative/popular-web-designs/templates/minimax.md +270 -0
- package/skills/creative/popular-web-designs/templates/mintlify.md +339 -0
- package/skills/creative/popular-web-designs/templates/miro.md +121 -0
- package/skills/creative/popular-web-designs/templates/mistral.ai.md +274 -0
- package/skills/creative/popular-web-designs/templates/mongodb.md +279 -0
- package/skills/creative/popular-web-designs/templates/notion.md +322 -0
- package/skills/creative/popular-web-designs/templates/nvidia.md +306 -0
- package/skills/creative/popular-web-designs/templates/ollama.md +280 -0
- package/skills/creative/popular-web-designs/templates/opencode.ai.md +294 -0
- package/skills/creative/popular-web-designs/templates/pinterest.md +243 -0
- package/skills/creative/popular-web-designs/templates/posthog.md +269 -0
- package/skills/creative/popular-web-designs/templates/raycast.md +281 -0
- package/skills/creative/popular-web-designs/templates/replicate.md +274 -0
- package/skills/creative/popular-web-designs/templates/resend.md +316 -0
- package/skills/creative/popular-web-designs/templates/revolut.md +198 -0
- package/skills/creative/popular-web-designs/templates/runwayml.md +257 -0
- package/skills/creative/popular-web-designs/templates/sanity.md +370 -0
- package/skills/creative/popular-web-designs/templates/sentry.md +275 -0
- package/skills/creative/popular-web-designs/templates/spacex.md +207 -0
- package/skills/creative/popular-web-designs/templates/spotify.md +259 -0
- package/skills/creative/popular-web-designs/templates/stripe.md +335 -0
- package/skills/creative/popular-web-designs/templates/supabase.md +268 -0
- package/skills/creative/popular-web-designs/templates/superhuman.md +265 -0
- package/skills/creative/popular-web-designs/templates/together.ai.md +276 -0
- package/skills/creative/popular-web-designs/templates/uber.md +308 -0
- package/skills/creative/popular-web-designs/templates/vercel.md +323 -0
- package/skills/creative/popular-web-designs/templates/voltagent.md +336 -0
- package/skills/creative/popular-web-designs/templates/warp.md +266 -0
- package/skills/creative/popular-web-designs/templates/webflow.md +105 -0
- package/skills/creative/popular-web-designs/templates/wise.md +186 -0
- package/skills/creative/popular-web-designs/templates/x.ai.md +270 -0
- package/skills/creative/popular-web-designs/templates/zapier.md +341 -0
- package/skills/creative/pretext/SKILL.md +220 -0
- package/skills/creative/pretext/references/patterns.md +258 -0
- package/skills/creative/pretext/templates/donut-orbit.html +1468 -0
- package/skills/creative/pretext/templates/hello-orb-flow.html +95 -0
- package/skills/creative/sketch/SKILL.md +218 -0
- package/skills/creative/songwriting-and-ai-music/SKILL.md +287 -0
- package/skills/creative/touchdesigner-mcp/SKILL.md +356 -0
- package/skills/creative/touchdesigner-mcp/references/3d-scene.md +275 -0
- package/skills/creative/touchdesigner-mcp/references/animation.md +221 -0
- package/skills/creative/touchdesigner-mcp/references/audio-reactive.md +175 -0
- package/skills/creative/touchdesigner-mcp/references/dat-scripting.md +352 -0
- package/skills/creative/touchdesigner-mcp/references/external-data.md +322 -0
- package/skills/creative/touchdesigner-mcp/references/geometry-comp.md +121 -0
- package/skills/creative/touchdesigner-mcp/references/glsl.md +151 -0
- package/skills/creative/touchdesigner-mcp/references/layout-compositor.md +131 -0
- package/skills/creative/touchdesigner-mcp/references/mcp-tools.md +382 -0
- package/skills/creative/touchdesigner-mcp/references/midi-osc.md +211 -0
- package/skills/creative/touchdesigner-mcp/references/network-patterns.md +966 -0
- package/skills/creative/touchdesigner-mcp/references/operator-tips.md +106 -0
- package/skills/creative/touchdesigner-mcp/references/operators.md +239 -0
- package/skills/creative/touchdesigner-mcp/references/panel-ui.md +281 -0
- package/skills/creative/touchdesigner-mcp/references/particles.md +245 -0
- package/skills/creative/touchdesigner-mcp/references/pitfalls.md +704 -0
- package/skills/creative/touchdesigner-mcp/references/postfx.md +183 -0
- package/skills/creative/touchdesigner-mcp/references/projection-mapping.md +211 -0
- package/skills/creative/touchdesigner-mcp/references/python-api.md +463 -0
- package/skills/creative/touchdesigner-mcp/references/replicator.md +198 -0
- package/skills/creative/touchdesigner-mcp/references/troubleshooting.md +244 -0
- package/skills/creative/touchdesigner-mcp/scripts/setup.sh +115 -0
- package/skills/data-science/DESCRIPTION.md +3 -0
- package/skills/data-science/jupyter-live-kernel/SKILL.md +167 -0
- package/skills/devops/kanban-orchestrator/SKILL.md +189 -0
- package/skills/devops/kanban-worker/SKILL.md +184 -0
- package/skills/devops/webhook-subscriptions/SKILL.md +204 -0
- package/skills/diagramming/DESCRIPTION.md +3 -0
- package/skills/dogfood/SKILL.md +162 -0
- package/skills/dogfood/references/issue-taxonomy.md +109 -0
- package/skills/dogfood/templates/dogfood-report-template.md +86 -0
- package/skills/domain/DESCRIPTION.md +24 -0
- package/skills/email/DESCRIPTION.md +3 -0
- package/skills/email/himalaya/SKILL.md +299 -0
- package/skills/email/himalaya/references/configuration.md +227 -0
- package/skills/email/himalaya/references/message-composition.md +199 -0
- package/skills/gaming/DESCRIPTION.md +3 -0
- package/skills/gaming/minecraft-modpack-server/SKILL.md +187 -0
- package/skills/gaming/pokemon-player/SKILL.md +216 -0
- package/skills/gifs/DESCRIPTION.md +3 -0
- package/skills/github/DESCRIPTION.md +3 -0
- package/skills/github/codebase-inspection/SKILL.md +116 -0
- package/skills/github/github-auth/SKILL.md +247 -0
- package/skills/github/github-auth/scripts/gh-env.sh +66 -0
- package/skills/github/github-code-review/SKILL.md +481 -0
- package/skills/github/github-code-review/references/review-output-template.md +74 -0
- package/skills/github/github-issues/SKILL.md +370 -0
- package/skills/github/github-issues/templates/bug-report.md +35 -0
- package/skills/github/github-issues/templates/feature-request.md +31 -0
- package/skills/github/github-pr-workflow/SKILL.md +367 -0
- package/skills/github/github-pr-workflow/references/ci-troubleshooting.md +183 -0
- package/skills/github/github-pr-workflow/references/conventional-commits.md +71 -0
- package/skills/github/github-pr-workflow/templates/pr-body-bugfix.md +35 -0
- package/skills/github/github-pr-workflow/templates/pr-body-feature.md +33 -0
- package/skills/github/github-repo-management/SKILL.md +516 -0
- package/skills/github/github-repo-management/references/github-api-cheatsheet.md +161 -0
- package/skills/index-cache/anthropics_skills_skills_.json +1 -0
- package/skills/index-cache/claude_marketplace_anthropics_skills.json +1 -0
- package/skills/index-cache/lobehub_index.json +1 -0
- package/skills/index-cache/openai_skills_skills_.json +1 -0
- package/skills/inference-sh/DESCRIPTION.md +19 -0
- package/skills/mcp/DESCRIPTION.md +3 -0
- package/skills/mcp/native-mcp/SKILL.md +357 -0
- package/skills/media/DESCRIPTION.md +3 -0
- package/skills/media/gif-search/SKILL.md +91 -0
- package/skills/media/heartmula/SKILL.md +171 -0
- package/skills/media/songsee/SKILL.md +83 -0
- package/skills/media/spotify/SKILL.md +135 -0
- package/skills/media/youtube-content/SKILL.md +73 -0
- package/skills/media/youtube-content/references/output-formats.md +56 -0
- package/skills/media/youtube-content/scripts/fetch_transcript.py +124 -0
- package/skills/mlops/DESCRIPTION.md +3 -0
- package/skills/mlops/evaluation/DESCRIPTION.md +3 -0
- package/skills/mlops/evaluation/lm-evaluation-harness/SKILL.md +498 -0
- package/skills/mlops/evaluation/lm-evaluation-harness/references/api-evaluation.md +490 -0
- package/skills/mlops/evaluation/lm-evaluation-harness/references/benchmark-guide.md +488 -0
- package/skills/mlops/evaluation/lm-evaluation-harness/references/custom-tasks.md +602 -0
- package/skills/mlops/evaluation/lm-evaluation-harness/references/distributed-eval.md +519 -0
- package/skills/mlops/evaluation/weights-and-biases/SKILL.md +594 -0
- package/skills/mlops/evaluation/weights-and-biases/references/artifacts.md +584 -0
- package/skills/mlops/evaluation/weights-and-biases/references/integrations.md +700 -0
- package/skills/mlops/evaluation/weights-and-biases/references/sweeps.md +847 -0
- package/skills/mlops/huggingface-hub/SKILL.md +81 -0
- package/skills/mlops/inference/DESCRIPTION.md +3 -0
- package/skills/mlops/inference/llama-cpp/SKILL.md +249 -0
- package/skills/mlops/inference/llama-cpp/references/advanced-usage.md +504 -0
- package/skills/mlops/inference/llama-cpp/references/hub-discovery.md +168 -0
- package/skills/mlops/inference/llama-cpp/references/optimization.md +89 -0
- package/skills/mlops/inference/llama-cpp/references/quantization.md +243 -0
- package/skills/mlops/inference/llama-cpp/references/server.md +150 -0
- package/skills/mlops/inference/llama-cpp/references/troubleshooting.md +442 -0
- package/skills/mlops/inference/obliteratus/SKILL.md +342 -0
- package/skills/mlops/inference/obliteratus/references/analysis-modules.md +166 -0
- package/skills/mlops/inference/obliteratus/references/methods-guide.md +141 -0
- package/skills/mlops/inference/obliteratus/templates/abliteration-config.yaml +33 -0
- package/skills/mlops/inference/obliteratus/templates/analysis-study.yaml +40 -0
- package/skills/mlops/inference/obliteratus/templates/batch-abliteration.yaml +41 -0
- package/skills/mlops/inference/vllm/SKILL.md +372 -0
- package/skills/mlops/inference/vllm/references/optimization.md +226 -0
- package/skills/mlops/inference/vllm/references/quantization.md +284 -0
- package/skills/mlops/inference/vllm/references/server-deployment.md +255 -0
- package/skills/mlops/inference/vllm/references/troubleshooting.md +447 -0
- package/skills/mlops/models/DESCRIPTION.md +3 -0
- package/skills/mlops/models/audiocraft/SKILL.md +568 -0
- package/skills/mlops/models/audiocraft/references/advanced-usage.md +666 -0
- package/skills/mlops/models/audiocraft/references/troubleshooting.md +504 -0
- package/skills/mlops/models/segment-anything/SKILL.md +506 -0
- package/skills/mlops/models/segment-anything/references/advanced-usage.md +589 -0
- package/skills/mlops/models/segment-anything/references/troubleshooting.md +484 -0
- package/skills/mlops/research/DESCRIPTION.md +3 -0
- package/skills/mlops/research/dspy/SKILL.md +594 -0
- package/skills/mlops/research/dspy/references/examples.md +663 -0
- package/skills/mlops/research/dspy/references/modules.md +475 -0
- package/skills/mlops/research/dspy/references/optimizers.md +566 -0
- package/skills/mlops/training/DESCRIPTION.md +3 -0
- package/skills/mlops/vector-databases/DESCRIPTION.md +3 -0
- package/skills/note-taking/DESCRIPTION.md +3 -0
- package/skills/note-taking/obsidian/SKILL.md +61 -0
- package/skills/productivity/DESCRIPTION.md +3 -0
- package/skills/productivity/airtable/SKILL.md +229 -0
- package/skills/productivity/google-workspace/SKILL.md +335 -0
- package/skills/productivity/google-workspace/references/gmail-search-syntax.md +63 -0
- package/skills/productivity/google-workspace/scripts/_hermes_home.py +43 -0
- package/skills/productivity/google-workspace/scripts/google_api.py +1221 -0
- package/skills/productivity/google-workspace/scripts/gws_bridge.py +108 -0
- package/skills/productivity/google-workspace/scripts/setup.py +454 -0
- package/skills/productivity/linear/SKILL.md +380 -0
- package/skills/productivity/linear/scripts/linear_api.py +445 -0
- package/skills/productivity/maps/SKILL.md +195 -0
- package/skills/productivity/maps/scripts/maps_client.py +1298 -0
- package/skills/productivity/nano-pdf/SKILL.md +52 -0
- package/skills/productivity/notion/SKILL.md +448 -0
- package/skills/productivity/notion/references/block-types.md +112 -0
- package/skills/productivity/ocr-and-documents/DESCRIPTION.md +3 -0
- package/skills/productivity/ocr-and-documents/SKILL.md +172 -0
- package/skills/productivity/ocr-and-documents/scripts/extract_marker.py +87 -0
- package/skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py +98 -0
- package/skills/productivity/powerpoint/LICENSE.txt +30 -0
- package/skills/productivity/powerpoint/SKILL.md +237 -0
- package/skills/productivity/powerpoint/editing.md +205 -0
- package/skills/productivity/powerpoint/pptxgenjs.md +420 -0
- package/skills/productivity/powerpoint/scripts/__init__.py +0 -0
- package/skills/productivity/powerpoint/scripts/add_slide.py +195 -0
- package/skills/productivity/powerpoint/scripts/clean.py +286 -0
- package/skills/productivity/powerpoint/scripts/office/helpers/__init__.py +0 -0
- package/skills/productivity/powerpoint/scripts/office/helpers/merge_runs.py +199 -0
- package/skills/productivity/powerpoint/scripts/office/helpers/simplify_redlines.py +197 -0
- package/skills/productivity/powerpoint/scripts/office/pack.py +159 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-contentTypes.xsd +42 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-coreProperties.xsd +50 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-digSig.xsd +49 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-relationships.xsd +33 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/mce/mc.xsd +75 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
- package/skills/productivity/teams-meeting-pipeline/SKILL.md +116 -0
- package/skills/red-teaming/godmode/SKILL.md +404 -0
- package/skills/red-teaming/godmode/references/jailbreak-templates.md +128 -0
- package/skills/red-teaming/godmode/references/refusal-detection.md +142 -0
- package/skills/red-teaming/godmode/scripts/auto_jailbreak.py +769 -0
- package/skills/red-teaming/godmode/scripts/godmode_race.py +530 -0
- package/skills/red-teaming/godmode/scripts/load_godmode.py +45 -0
- package/skills/red-teaming/godmode/scripts/parseltongue.py +550 -0
- package/skills/red-teaming/godmode/templates/prefill-subtle.json +10 -0
- package/skills/red-teaming/godmode/templates/prefill.json +18 -0
- package/skills/research/DESCRIPTION.md +3 -0
- package/skills/research/arxiv/SKILL.md +282 -0
- package/skills/research/arxiv/scripts/search_arxiv.py +114 -0
- package/skills/research/blogwatcher/SKILL.md +137 -0
- package/skills/research/llm-wiki/SKILL.md +507 -0
- package/skills/research/polymarket/SKILL.md +77 -0
- package/skills/research/polymarket/references/api-endpoints.md +220 -0
- package/skills/research/polymarket/scripts/polymarket.py +284 -0
- package/skills/research/research-paper-writing/SKILL.md +2377 -0
- package/skills/research/research-paper-writing/references/autoreason-methodology.md +394 -0
- package/skills/research/research-paper-writing/references/checklists.md +434 -0
- package/skills/research/research-paper-writing/references/citation-workflow.md +564 -0
- package/skills/research/research-paper-writing/references/experiment-patterns.md +728 -0
- package/skills/research/research-paper-writing/references/human-evaluation.md +476 -0
- package/skills/research/research-paper-writing/references/paper-types.md +481 -0
- package/skills/research/research-paper-writing/references/reviewer-guidelines.md +433 -0
- package/skills/research/research-paper-writing/references/sources.md +191 -0
- package/skills/research/research-paper-writing/references/writing-guide.md +474 -0
- package/skills/research/research-paper-writing/templates/README.md +251 -0
- package/skills/research/research-paper-writing/templates/aaai2026/README.md +534 -0
- package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
- package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-template.tex +952 -0
- package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bib +111 -0
- package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bst +1493 -0
- package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.sty +315 -0
- package/skills/research/research-paper-writing/templates/acl/README.md +50 -0
- package/skills/research/research-paper-writing/templates/acl/acl.sty +312 -0
- package/skills/research/research-paper-writing/templates/acl/acl_latex.tex +377 -0
- package/skills/research/research-paper-writing/templates/acl/acl_lualatex.tex +101 -0
- package/skills/research/research-paper-writing/templates/acl/acl_natbib.bst +1940 -0
- package/skills/research/research-paper-writing/templates/acl/anthology.bib.txt +26 -0
- package/skills/research/research-paper-writing/templates/acl/custom.bib +70 -0
- package/skills/research/research-paper-writing/templates/acl/formatting.md +326 -0
- package/skills/research/research-paper-writing/templates/colm2025/README.md +3 -0
- package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bib +11 -0
- package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bst +1440 -0
- package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.pdf +0 -0
- package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.sty +218 -0
- package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.tex +305 -0
- package/skills/research/research-paper-writing/templates/colm2025/fancyhdr.sty +485 -0
- package/skills/research/research-paper-writing/templates/colm2025/math_commands.tex +508 -0
- package/skills/research/research-paper-writing/templates/colm2025/natbib.sty +1246 -0
- package/skills/research/research-paper-writing/templates/iclr2026/fancyhdr.sty +485 -0
- package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bib +24 -0
- package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bst +1440 -0
- package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.pdf +0 -0
- package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.sty +246 -0
- package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.tex +414 -0
- package/skills/research/research-paper-writing/templates/iclr2026/math_commands.tex +508 -0
- package/skills/research/research-paper-writing/templates/iclr2026/natbib.sty +1246 -0
- package/skills/research/research-paper-writing/templates/icml2026/algorithm.sty +79 -0
- package/skills/research/research-paper-writing/templates/icml2026/algorithmic.sty +201 -0
- package/skills/research/research-paper-writing/templates/icml2026/example_paper.bib +75 -0
- package/skills/research/research-paper-writing/templates/icml2026/example_paper.pdf +0 -0
- package/skills/research/research-paper-writing/templates/icml2026/example_paper.tex +662 -0
- package/skills/research/research-paper-writing/templates/icml2026/fancyhdr.sty +864 -0
- package/skills/research/research-paper-writing/templates/icml2026/icml2026.bst +1443 -0
- package/skills/research/research-paper-writing/templates/icml2026/icml2026.sty +767 -0
- package/skills/research/research-paper-writing/templates/icml2026/icml_numpapers.pdf +0 -0
- package/skills/research/research-paper-writing/templates/neurips2025/Makefile +36 -0
- package/skills/research/research-paper-writing/templates/neurips2025/extra_pkgs.tex +53 -0
- package/skills/research/research-paper-writing/templates/neurips2025/main.tex +38 -0
- package/skills/research/research-paper-writing/templates/neurips2025/neurips.sty +382 -0
- package/skills/smart-home/DESCRIPTION.md +3 -0
- package/skills/smart-home/openhue/SKILL.md +109 -0
- package/skills/social-media/DESCRIPTION.md +3 -0
- package/skills/social-media/xurl/SKILL.md +414 -0
- package/skills/software-development/debugging-hermes-tui-commands/SKILL.md +152 -0
- package/skills/software-development/hermes-agent-skill-authoring/SKILL.md +165 -0
- package/skills/software-development/node-inspect-debugger/SKILL.md +319 -0
- package/skills/software-development/plan/SKILL.md +58 -0
- package/skills/software-development/python-debugpy/SKILL.md +375 -0
- package/skills/software-development/requesting-code-review/SKILL.md +280 -0
- package/skills/software-development/spike/SKILL.md +197 -0
- package/skills/software-development/subagent-driven-development/SKILL.md +352 -0
- package/skills/software-development/subagent-driven-development/references/context-budget-discipline.md +53 -0
- package/skills/software-development/subagent-driven-development/references/gates-taxonomy.md +93 -0
- package/skills/software-development/systematic-debugging/SKILL.md +367 -0
- package/skills/software-development/test-driven-development/SKILL.md +343 -0
- package/skills/software-development/writing-plans/SKILL.md +297 -0
- package/skills/yuanbao/SKILL.md +108 -0
- package/tools/__init__.py +25 -0
- package/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- package/tools/__pycache__/approval.cpython-312.pyc +0 -0
- package/tools/__pycache__/binary_extensions.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_camofox.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_camofox_state.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_cdp_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_dialog_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_supervisor.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/budget_config.cpython-312.pyc +0 -0
- package/tools/__pycache__/checkpoint_manager.cpython-312.pyc +0 -0
- package/tools/__pycache__/clarify_gateway.cpython-312.pyc +0 -0
- package/tools/__pycache__/clarify_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/code_execution_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/computer_use_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/cronjob_tools.cpython-312.pyc +0 -0
- package/tools/__pycache__/debug_helpers.cpython-312.pyc +0 -0
- package/tools/__pycache__/delegate_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/discord_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/feishu_doc_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/feishu_drive_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/file_operations.cpython-312.pyc +0 -0
- package/tools/__pycache__/file_state.cpython-312.pyc +0 -0
- package/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
- package/tools/__pycache__/homeassistant_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/image_generation_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/interrupt.cpython-312.pyc +0 -0
- package/tools/__pycache__/kanban_tools.cpython-312.pyc +0 -0
- package/tools/__pycache__/lazy_deps.cpython-312.pyc +0 -0
- package/tools/__pycache__/managed_tool_gateway.cpython-312.pyc +0 -0
- package/tools/__pycache__/mcp_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/memory_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/mixture_of_agents_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/openrouter_client.cpython-312.pyc +0 -0
- package/tools/__pycache__/process_registry.cpython-312.pyc +0 -0
- package/tools/__pycache__/registry.cpython-312.pyc +0 -0
- package/tools/__pycache__/schema_sanitizer.cpython-312.pyc +0 -0
- package/tools/__pycache__/send_message_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/session_search_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/skill_manager_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/skill_provenance.cpython-312.pyc +0 -0
- package/tools/__pycache__/skill_usage.cpython-312.pyc +0 -0
- package/tools/__pycache__/skills_guard.cpython-312.pyc +0 -0
- package/tools/__pycache__/skills_sync.cpython-312.pyc +0 -0
- package/tools/__pycache__/skills_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/slash_confirm.cpython-312.pyc +0 -0
- package/tools/__pycache__/terminal_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/tirith_security.cpython-312.pyc +0 -0
- package/tools/__pycache__/todo_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/tool_backend_helpers.cpython-312.pyc +0 -0
- package/tools/__pycache__/tool_result_storage.cpython-312.pyc +0 -0
- package/tools/__pycache__/tts_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/url_safety.cpython-312.pyc +0 -0
- package/tools/__pycache__/video_generation_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/vision_tools.cpython-312.pyc +0 -0
- package/tools/__pycache__/voice_mode.cpython-312.pyc +0 -0
- package/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
- package/tools/__pycache__/website_policy.cpython-312.pyc +0 -0
- package/tools/__pycache__/x_search_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/xai_http.cpython-312.pyc +0 -0
- package/tools/__pycache__/yuanbao_tools.cpython-312.pyc +0 -0
- package/tools/ansi_strip.py +44 -0
- package/tools/approval.py +1392 -0
- package/tools/binary_extensions.py +42 -0
- package/tools/browser_camofox.py +700 -0
- package/tools/browser_camofox_state.py +48 -0
- package/tools/browser_cdp_tool.py +569 -0
- package/tools/browser_dialog_tool.py +148 -0
- package/tools/browser_providers/__init__.py +10 -0
- package/tools/browser_providers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/tools/browser_providers/__pycache__/base.cpython-312.pyc +0 -0
- package/tools/browser_providers/__pycache__/browser_use.cpython-312.pyc +0 -0
- package/tools/browser_providers/__pycache__/browserbase.cpython-312.pyc +0 -0
- package/tools/browser_providers/__pycache__/firecrawl.cpython-312.pyc +0 -0
- package/tools/browser_providers/base.py +59 -0
- package/tools/browser_providers/browser_use.py +225 -0
- package/tools/browser_providers/browserbase.py +222 -0
- package/tools/browser_providers/firecrawl.py +112 -0
- package/tools/browser_supervisor.py +1457 -0
- package/tools/browser_tool.py +3676 -0
- package/tools/budget_config.py +51 -0
- package/tools/checkpoint_manager.py +1639 -0
- package/tools/clarify_gateway.py +278 -0
- package/tools/clarify_tool.py +141 -0
- package/tools/code_execution_tool.py +1782 -0
- package/tools/computer_use/__init__.py +43 -0
- package/tools/computer_use/__pycache__/__init__.cpython-312.pyc +0 -0
- package/tools/computer_use/__pycache__/backend.cpython-312.pyc +0 -0
- package/tools/computer_use/__pycache__/schema.cpython-312.pyc +0 -0
- package/tools/computer_use/__pycache__/tool.cpython-312.pyc +0 -0
- package/tools/computer_use/backend.py +150 -0
- package/tools/computer_use/cua_backend.py +682 -0
- package/tools/computer_use/schema.py +191 -0
- package/tools/computer_use/tool.py +521 -0
- package/tools/computer_use_tool.py +39 -0
- package/tools/credential_files.py +437 -0
- package/tools/cronjob_tools.py +719 -0
- package/tools/debug_helpers.py +106 -0
- package/tools/delegate_tool.py +2797 -0
- package/tools/discord_tool.py +959 -0
- package/tools/env_passthrough.py +145 -0
- package/tools/environments/__init__.py +14 -0
- package/tools/environments/__pycache__/__init__.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/base.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/docker.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/file_sync.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/local.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/managed_modal.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/modal.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/modal_utils.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/singularity.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/ssh.cpython-312.pyc +0 -0
- package/tools/environments/base.py +844 -0
- package/tools/environments/daytona.py +270 -0
- package/tools/environments/docker.py +656 -0
- package/tools/environments/file_sync.py +400 -0
- package/tools/environments/local.py +658 -0
- package/tools/environments/managed_modal.py +282 -0
- package/tools/environments/modal.py +479 -0
- package/tools/environments/modal_utils.py +199 -0
- package/tools/environments/singularity.py +263 -0
- package/tools/environments/ssh.py +295 -0
- package/tools/environments/vercel_sandbox.py +655 -0
- package/tools/feishu_doc_tool.py +138 -0
- package/tools/feishu_drive_tool.py +431 -0
- package/tools/file_operations.py +1825 -0
- package/tools/file_state.py +332 -0
- package/tools/file_tools.py +1172 -0
- package/tools/fuzzy_match.py +703 -0
- package/tools/homeassistant_tool.py +513 -0
- package/tools/image_generation_tool.py +1098 -0
- package/tools/interrupt.py +98 -0
- package/tools/kanban_tools.py +1139 -0
- package/tools/lazy_deps.py +608 -0
- package/tools/managed_tool_gateway.py +168 -0
- package/tools/mcp_oauth.py +633 -0
- package/tools/mcp_oauth_manager.py +607 -0
- package/tools/mcp_tool.py +3483 -0
- package/tools/memory_tool.py +584 -0
- package/tools/microsoft_graph_auth.py +245 -0
- package/tools/microsoft_graph_client.py +408 -0
- package/tools/mixture_of_agents_tool.py +542 -0
- package/tools/neutts_samples/jo.txt +1 -0
- package/tools/neutts_samples/jo.wav +0 -0
- package/tools/neutts_synth.py +104 -0
- package/tools/openrouter_client.py +33 -0
- package/tools/osv_check.py +155 -0
- package/tools/patch_parser.py +592 -0
- package/tools/path_security.py +43 -0
- package/tools/process_registry.py +1534 -0
- package/tools/registry.py +589 -0
- package/tools/schema_sanitizer.py +370 -0
- package/tools/send_message_tool.py +1900 -0
- package/tools/session_search_tool.py +613 -0
- package/tools/skill_manager_tool.py +932 -0
- package/tools/skill_provenance.py +78 -0
- package/tools/skill_usage.py +610 -0
- package/tools/skills_guard.py +932 -0
- package/tools/skills_hub.py +3263 -0
- package/tools/skills_sync.py +432 -0
- package/tools/skills_tool.py +1569 -0
- package/tools/slash_confirm.py +167 -0
- package/tools/terminal_tool.py +2376 -0
- package/tools/tirith_security.py +775 -0
- package/tools/todo_tool.py +277 -0
- package/tools/tool_backend_helpers.py +144 -0
- package/tools/tool_output_limits.py +92 -0
- package/tools/tool_result_storage.py +232 -0
- package/tools/transcription_tools.py +936 -0
- package/tools/tts_tool.py +2285 -0
- package/tools/url_safety.py +330 -0
- package/tools/video_generation_tool.py +561 -0
- package/tools/vision_tools.py +1422 -0
- package/tools/voice_mode.py +1019 -0
- package/tools/web_tools.py +1551 -0
- package/tools/website_policy.py +283 -0
- package/tools/x_search_tool.py +424 -0
- package/tools/xai_http.py +83 -0
- package/tools/yuanbao_tools.py +736 -0
- package/toolset_distributions.py +364 -0
- package/toolsets.py +866 -0
- package/trajectory_compressor.py +1509 -0
- package/tui_gateway/__init__.py +0 -0
- package/tui_gateway/entry.py +251 -0
- package/tui_gateway/event_publisher.py +126 -0
- package/tui_gateway/render.py +49 -0
- package/tui_gateway/server.py +6623 -0
- package/tui_gateway/slash_worker.py +76 -0
- package/tui_gateway/transport.py +219 -0
- package/tui_gateway/ws.py +178 -0
- package/utils.py +361 -0
|
@@ -0,0 +1,3343 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google Chat platform adapter.
|
|
3
|
+
|
|
4
|
+
Uses Google Cloud Pub/Sub (pull subscription) for inbound events and the
|
|
5
|
+
Google Chat REST API for outbound messages. Pattern parallels Slack Socket
|
|
6
|
+
Mode and Telegram long-polling: no public endpoint required.
|
|
7
|
+
|
|
8
|
+
Concurrency model
|
|
9
|
+
-----------------
|
|
10
|
+
The Pub/Sub SubscriberClient invokes its message callback in a background
|
|
11
|
+
thread (managed by the client's internal executor). The adapter's
|
|
12
|
+
``handle_message`` coroutine must run on the asyncio event loop, so the
|
|
13
|
+
callback uses ``asyncio.run_coroutine_threadsafe`` with
|
|
14
|
+
``add_done_callback`` (never ``.result()`` — that would block the callback
|
|
15
|
+
thread and saturate the Pub/Sub executor under load).
|
|
16
|
+
|
|
17
|
+
All outbound Chat REST calls go through ``asyncio.to_thread`` because the
|
|
18
|
+
googleapiclient is synchronous. This keeps the event loop responsive.
|
|
19
|
+
|
|
20
|
+
Pub/Sub delivery diagram::
|
|
21
|
+
|
|
22
|
+
Pub/Sub stream -> callback thread -> asyncio loop
|
|
23
|
+
(streaming_pull) (_on_pubsub_message) (handle_message)
|
|
24
|
+
| | |
|
|
25
|
+
| at-least-once | parse + dedup | agent work
|
|
26
|
+
| delivery | _submit_on_loop | send() response
|
|
27
|
+
| | message.ack() |
|
|
28
|
+
v v v
|
|
29
|
+
|
|
30
|
+
Event type routing
|
|
31
|
+
------------------
|
|
32
|
+
Inbound envelope carries ``type`` in [MESSAGE, ADDED_TO_SPACE, REMOVED_FROM_SPACE,
|
|
33
|
+
CARD_CLICKED]. Only MESSAGE dispatches to the agent. ADDED_TO_SPACE caches the
|
|
34
|
+
bot's resource name (belt-and-suspenders on top of eager resolution in connect()).
|
|
35
|
+
CARD_CLICKED is ACK'd only in v1 (follow-up PR implements interactivity).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import asyncio
|
|
41
|
+
import json
|
|
42
|
+
import logging
|
|
43
|
+
import os
|
|
44
|
+
import random
|
|
45
|
+
import re
|
|
46
|
+
from pathlib import Path as _Path
|
|
47
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
48
|
+
|
|
49
|
+
# Heavy google-cloud + googleapiclient imports are deferred to first
|
|
50
|
+
# adapter use. Importing them eagerly here added ~110ms wall and ~33MB
|
|
51
|
+
# RSS to *every* CLI invocation (the plugin loader imports this module at
|
|
52
|
+
# ``model_tools`` import time, so ``hermes status``, ``hermes chat``, etc.
|
|
53
|
+
# all paid the cost even though they never instantiate the adapter).
|
|
54
|
+
#
|
|
55
|
+
# All names below are module globals that ``_load_google_modules()``
|
|
56
|
+
# rebinds on first call. The ``HttpError = Exception`` placeholder is
|
|
57
|
+
# important: ``except HttpError as exc:`` clauses elsewhere in this
|
|
58
|
+
# module bind the *current* module-global at try/except evaluation time,
|
|
59
|
+
# so as long as ``_load_google_modules()`` runs before any such
|
|
60
|
+
# ``try`` block executes (which it does — ``__init__`` calls it), the
|
|
61
|
+
# rebound real ``googleapiclient.errors.HttpError`` is what actually
|
|
62
|
+
# matches at runtime.
|
|
63
|
+
GOOGLE_CHAT_AVAILABLE: bool = False
|
|
64
|
+
httplib2: Any = None # type: ignore
|
|
65
|
+
pubsub_v1: Any = None # type: ignore
|
|
66
|
+
gax_exceptions: Any = None # type: ignore
|
|
67
|
+
service_account: Any = None # type: ignore
|
|
68
|
+
AuthorizedHttp: Any = None # type: ignore
|
|
69
|
+
build_service: Any = None # type: ignore
|
|
70
|
+
HttpError: Any = Exception # type: ignore
|
|
71
|
+
MediaFileUpload: Any = None # type: ignore
|
|
72
|
+
|
|
73
|
+
_google_modules_loaded: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_google_modules() -> bool:
|
|
77
|
+
"""Lazily import the heavy google-cloud + googleapiclient stack.
|
|
78
|
+
|
|
79
|
+
Idempotent. Returns True if the optional deps are installed and
|
|
80
|
+
were successfully imported, False otherwise. On success, mutates
|
|
81
|
+
the module globals so existing code using ``pubsub_v1``,
|
|
82
|
+
``service_account``, ``HttpError``, etc. transparently uses the
|
|
83
|
+
real classes.
|
|
84
|
+
|
|
85
|
+
Why deferred: the import chain pulls in google.cloud.pubsub_v1,
|
|
86
|
+
googleapiclient, grpc, and friends — about 33MB RSS and 110ms wall
|
|
87
|
+
on a fresh interpreter. Plugin discovery imports this module on
|
|
88
|
+
every CLI invocation, even ones that never touch a gateway.
|
|
89
|
+
"""
|
|
90
|
+
global GOOGLE_CHAT_AVAILABLE, _google_modules_loaded
|
|
91
|
+
global httplib2, pubsub_v1, gax_exceptions, service_account
|
|
92
|
+
global AuthorizedHttp, build_service, HttpError, MediaFileUpload
|
|
93
|
+
if _google_modules_loaded:
|
|
94
|
+
return GOOGLE_CHAT_AVAILABLE
|
|
95
|
+
_google_modules_loaded = True
|
|
96
|
+
try:
|
|
97
|
+
import httplib2 as _httplib2
|
|
98
|
+
from google.cloud import pubsub_v1 as _pubsub_v1
|
|
99
|
+
from google.api_core import exceptions as _gax_exceptions
|
|
100
|
+
from google.oauth2 import service_account as _service_account
|
|
101
|
+
from google_auth_httplib2 import AuthorizedHttp as _AuthorizedHttp
|
|
102
|
+
from googleapiclient.discovery import build as _build_service
|
|
103
|
+
from googleapiclient.errors import HttpError as _HttpError
|
|
104
|
+
from googleapiclient.http import MediaFileUpload as _MediaFileUpload
|
|
105
|
+
except ImportError:
|
|
106
|
+
GOOGLE_CHAT_AVAILABLE = False
|
|
107
|
+
return False
|
|
108
|
+
httplib2 = _httplib2
|
|
109
|
+
pubsub_v1 = _pubsub_v1
|
|
110
|
+
gax_exceptions = _gax_exceptions
|
|
111
|
+
service_account = _service_account
|
|
112
|
+
AuthorizedHttp = _AuthorizedHttp
|
|
113
|
+
build_service = _build_service
|
|
114
|
+
HttpError = _HttpError
|
|
115
|
+
MediaFileUpload = _MediaFileUpload
|
|
116
|
+
GOOGLE_CHAT_AVAILABLE = True
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
from gateway.config import Platform, PlatformConfig
|
|
120
|
+
|
|
121
|
+
# Trigger registration of the dynamic ``google_chat`` enum member at module
|
|
122
|
+
# import time. ``_missing_()`` caches the pseudo-member in
|
|
123
|
+
# ``_value2member_map_`` *and* ``_member_map_``, so after this call
|
|
124
|
+
# ``Platform.GOOGLE_CHAT`` resolves via attribute access too. Without this
|
|
125
|
+
# line, any code (including tests) that references ``Platform.GOOGLE_CHAT``
|
|
126
|
+
# before an adapter instance is constructed would hit ``AttributeError``.
|
|
127
|
+
# Built-ins avoid this because they have explicit enum members; plugin
|
|
128
|
+
# platforms earn the attribute by asking for it once.
|
|
129
|
+
Platform("google_chat")
|
|
130
|
+
from gateway.platforms.helpers import MessageDeduplicator
|
|
131
|
+
from gateway.platforms.base import (
|
|
132
|
+
BasePlatformAdapter,
|
|
133
|
+
MessageEvent,
|
|
134
|
+
MessageType,
|
|
135
|
+
ProcessingOutcome,
|
|
136
|
+
SendResult,
|
|
137
|
+
cache_audio_from_bytes,
|
|
138
|
+
cache_document_from_bytes,
|
|
139
|
+
cache_image_from_bytes,
|
|
140
|
+
cache_video_from_bytes,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Pin the logger name to the legacy module path so operator log filters,
|
|
145
|
+
# grep aliases, and the gateway's bundled log views keep matching after
|
|
146
|
+
# the in-tree → plugin migration. ``__name__`` resolves to
|
|
147
|
+
# ``hermes_plugins.platforms__google_chat.adapter`` once the plugin
|
|
148
|
+
# loader namespaces this module, which would silently break every
|
|
149
|
+
# downstream log-monitor that greps for ``gateway.platforms.google_chat``.
|
|
150
|
+
logger = logging.getLogger("gateway.platforms.google_chat")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Regex validating Pub/Sub subscription path format.
|
|
154
|
+
_SUBSCRIPTION_PATH_RE = re.compile(
|
|
155
|
+
r"^projects/(?P<project>[^/]+)/subscriptions/(?P<sub>[^/]+)$"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# SA scopes — chat.bot is sufficient for the bot's own messaging operations
|
|
159
|
+
# (messages.create / patch / delete, spaces metadata, memberships,
|
|
160
|
+
# media.download for inbound user attachments). The bot CANNOT call
|
|
161
|
+
# media.upload — Google requires user OAuth for that endpoint, no scope
|
|
162
|
+
# adjustment changes it.
|
|
163
|
+
#
|
|
164
|
+
# Native attachment delivery (bot → user) is handled via a separate user-
|
|
165
|
+
# OAuth flow in ``oauth.py`` (this plugin's helper module): the user grants the bot
|
|
166
|
+
# the chat.messages.create scope ONCE via an in-chat consent flow; the
|
|
167
|
+
# bot then calls media.upload on the user's behalf when sending files.
|
|
168
|
+
# See https://developers.google.com/chat/api/guides/auth/users
|
|
169
|
+
_CHAT_SCOPES = [
|
|
170
|
+
"https://www.googleapis.com/auth/chat.bot",
|
|
171
|
+
"https://www.googleapis.com/auth/pubsub",
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
# Google Chat text-message size limit is 4096; leave margin.
|
|
175
|
+
_MAX_TEXT_LENGTH = 4000
|
|
176
|
+
|
|
177
|
+
# Per-space rate-limit hit counter threshold; warn if exceeded.
|
|
178
|
+
_RATE_LIMIT_WARN_THRESHOLD = 5
|
|
179
|
+
|
|
180
|
+
# Outbound retry parameters. Google's Chat REST API returns transient 5xx
|
|
181
|
+
# and 429 occasionally — without a retry wrapper, single hiccups drop
|
|
182
|
+
# user-visible messages. Backoff stays bounded so a true outage is still
|
|
183
|
+
# surfaced quickly. Pattern lifted from PR #14965.
|
|
184
|
+
_RETRY_MAX_ATTEMPTS = 3
|
|
185
|
+
_RETRY_BASE_DELAY = 1.0
|
|
186
|
+
_RETRY_MAX_DELAY = 8.0
|
|
187
|
+
_RETRY_JITTER = 0.3
|
|
188
|
+
_RETRYABLE_HTTP_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_retryable_error(exc: BaseException) -> bool:
|
|
192
|
+
"""Classify outbound API errors as transient (retryable) vs permanent.
|
|
193
|
+
|
|
194
|
+
Retries are applied to:
|
|
195
|
+
- HTTP 429 (rate-limited)
|
|
196
|
+
- HTTP 5xx (server errors)
|
|
197
|
+
- Network/transport failures (timeout, connection reset, DNS)
|
|
198
|
+
|
|
199
|
+
Authentication errors (401/403), client errors (4xx other than 429),
|
|
200
|
+
and well-formed non-retryable failures are NOT retried — those
|
|
201
|
+
indicate a misconfiguration or revoked token, not a hiccup.
|
|
202
|
+
"""
|
|
203
|
+
# googleapiclient.errors.HttpError carries resp.status
|
|
204
|
+
resp = getattr(exc, "resp", None)
|
|
205
|
+
status = getattr(resp, "status", None)
|
|
206
|
+
if isinstance(status, int):
|
|
207
|
+
return status in _RETRYABLE_HTTP_STATUSES
|
|
208
|
+
# Fallback heuristics for SSL/socket errors that don't carry an
|
|
209
|
+
# HTTP status: text matches against common transport-layer wording.
|
|
210
|
+
text = str(exc).lower()
|
|
211
|
+
if "timeout" in text or "timed out" in text:
|
|
212
|
+
return True
|
|
213
|
+
if "connection" in text and ("reset" in text or "refused" in text or "aborted" in text):
|
|
214
|
+
return True
|
|
215
|
+
if "broken pipe" in text or "remote disconnected" in text:
|
|
216
|
+
return True
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
# Sentinel kept in ``_typing_messages`` after ``send()`` patches the typing
|
|
220
|
+
# marker into the agent's real response. Two purposes:
|
|
221
|
+
# * ``send_typing`` checks for any value before posting — sentinel keeps
|
|
222
|
+
# ``_keep_typing`` (running on the base-class timer) from creating a
|
|
223
|
+
# fresh "Hermes is thinking…" card during the small window between
|
|
224
|
+
# ``send()`` finishing and the base-class cancelling its typing_task.
|
|
225
|
+
# * ``stop_typing`` checks for the sentinel and skips the API delete —
|
|
226
|
+
# otherwise the safety-net cleanup at base.py:_process_message_background
|
|
227
|
+
# would delete the response we just patched and leave a tombstone.
|
|
228
|
+
_TYPING_CONSUMED_SENTINEL = "<consumed>"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def check_google_chat_requirements() -> bool:
|
|
232
|
+
"""Check if Google Chat optional dependencies are installed.
|
|
233
|
+
|
|
234
|
+
Triggers the lazy import of the google-cloud + googleapiclient stack
|
|
235
|
+
on first call. Subsequent calls hit the cached result. This is the
|
|
236
|
+
canonical "are the deps available" probe used by the plugin registry
|
|
237
|
+
and the adapter's own startup gate.
|
|
238
|
+
"""
|
|
239
|
+
return _load_google_modules()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# Hostnames we trust to host Google Chat attachment download URIs. Anything
|
|
243
|
+
# else gets rejected by _is_google_owned_host to block SSRF scenarios where
|
|
244
|
+
# a crafted event points downloadUri at a non-Google endpoint (e.g. the
|
|
245
|
+
# GCE/GKE metadata service at 169.254.169.254) and the bot's Service Account
|
|
246
|
+
# bearer token would be attached to the outbound request.
|
|
247
|
+
_TRUSTED_ATTACHMENT_HOSTS = (
|
|
248
|
+
"googleapis.com",
|
|
249
|
+
"chat.google.com",
|
|
250
|
+
"drive.google.com",
|
|
251
|
+
"docs.google.com",
|
|
252
|
+
"lh3.googleusercontent.com",
|
|
253
|
+
"lh4.googleusercontent.com",
|
|
254
|
+
"lh5.googleusercontent.com",
|
|
255
|
+
"lh6.googleusercontent.com",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _is_google_owned_host(url: str) -> bool:
|
|
260
|
+
"""Return True iff *url* is https and targets a Google-owned domain."""
|
|
261
|
+
try:
|
|
262
|
+
from urllib.parse import urlparse
|
|
263
|
+
|
|
264
|
+
parsed = urlparse(url)
|
|
265
|
+
except Exception:
|
|
266
|
+
return False
|
|
267
|
+
if parsed.scheme != "https":
|
|
268
|
+
return False
|
|
269
|
+
host = (parsed.hostname or "").lower()
|
|
270
|
+
if not host:
|
|
271
|
+
return False
|
|
272
|
+
return any(host == h or host.endswith("." + h) for h in _TRUSTED_ATTACHMENT_HOSTS)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _redact_sensitive(text: str) -> str:
|
|
276
|
+
"""Sanitize subscription paths and email-like tokens from an error string.
|
|
277
|
+
|
|
278
|
+
Covers project IDs leaking via Pub/Sub exception messages, plus SA-ish
|
|
279
|
+
email addresses. agent/redact.py handles log-level redaction elsewhere;
|
|
280
|
+
this helper is for user-facing error messages.
|
|
281
|
+
"""
|
|
282
|
+
if not text:
|
|
283
|
+
return text
|
|
284
|
+
text = re.sub(
|
|
285
|
+
r"projects/[^/\s]+/subscriptions/[^/\s]+",
|
|
286
|
+
"projects/<redacted>/subscriptions/<redacted>",
|
|
287
|
+
text,
|
|
288
|
+
)
|
|
289
|
+
text = re.sub(
|
|
290
|
+
r"projects/[^/\s]+/topics/[^/\s]+",
|
|
291
|
+
"projects/<redacted>/topics/<redacted>",
|
|
292
|
+
text,
|
|
293
|
+
)
|
|
294
|
+
text = re.sub(
|
|
295
|
+
r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.iam\.gserviceaccount\.com",
|
|
296
|
+
"<sa>@<project>.iam.gserviceaccount.com",
|
|
297
|
+
text,
|
|
298
|
+
)
|
|
299
|
+
return text
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _mime_for_message_type(mime: str) -> MessageType:
|
|
303
|
+
"""Map a MIME string to a hermes MessageType.
|
|
304
|
+
|
|
305
|
+
Anything not image/audio/video falls through to DOCUMENT so the agent
|
|
306
|
+
still receives the file.
|
|
307
|
+
"""
|
|
308
|
+
if not mime:
|
|
309
|
+
return MessageType.DOCUMENT
|
|
310
|
+
if mime.startswith("image/"):
|
|
311
|
+
return MessageType.PHOTO
|
|
312
|
+
if mime.startswith("audio/"):
|
|
313
|
+
return MessageType.AUDIO
|
|
314
|
+
if mime.startswith("video/"):
|
|
315
|
+
return MessageType.VIDEO
|
|
316
|
+
return MessageType.DOCUMENT
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class _ThreadCountStore:
|
|
320
|
+
"""Per-(chat_id, thread_name) inbound message counter, persisted to disk.
|
|
321
|
+
|
|
322
|
+
Drives the DM main-flow vs side-thread heuristic:
|
|
323
|
+
|
|
324
|
+
- prev_count == 0 (first time we see this thread) → "main flow":
|
|
325
|
+
Google Chat just auto-created a fresh thread for the user's
|
|
326
|
+
top-level message. Treat it as part of the shared DM session;
|
|
327
|
+
bot replies at top-level (no thread.name on outbound).
|
|
328
|
+
- prev_count >= 1 (we've already seen this thread) → "side thread":
|
|
329
|
+
user explicitly engaged a thread that's been around. Isolate
|
|
330
|
+
session by thread, route bot reply into the same thread.
|
|
331
|
+
|
|
332
|
+
Persistence is essential: without it, every gateway restart wipes
|
|
333
|
+
counts and active side-threads silently demote to "main flow",
|
|
334
|
+
which leaks main-flow context into the user's isolated thread
|
|
335
|
+
(the bug Ramón reported across 4 iterations of the in-memory
|
|
336
|
+
version).
|
|
337
|
+
|
|
338
|
+
File format (JSON):
|
|
339
|
+
{"<chat_id>": {"<thread_name>": <int_count>, ...}, ...}
|
|
340
|
+
|
|
341
|
+
Failure modes are non-fatal: a missing or corrupt file resets to
|
|
342
|
+
empty (logged as warning) so the adapter never crashes on disk
|
|
343
|
+
issues. The next ``incr`` will write a fresh file.
|
|
344
|
+
|
|
345
|
+
Save strategy: write-through after every ``incr``. The file is
|
|
346
|
+
tiny (a few KB even for very active bots), so the simplicity of
|
|
347
|
+
write-through outweighs the cost of debouncing for now.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
def __init__(self, path: _Path):
|
|
351
|
+
self._path = path
|
|
352
|
+
self._counts: Dict[str, Dict[str, int]] = {}
|
|
353
|
+
self._loaded = False
|
|
354
|
+
|
|
355
|
+
def load(self) -> None:
|
|
356
|
+
"""Load counts from disk. Safe to call multiple times.
|
|
357
|
+
|
|
358
|
+
Missing file → empty store. Corrupt JSON → empty store + warn.
|
|
359
|
+
"""
|
|
360
|
+
self._loaded = True
|
|
361
|
+
if not self._path.exists():
|
|
362
|
+
self._counts = {}
|
|
363
|
+
return
|
|
364
|
+
try:
|
|
365
|
+
raw = self._path.read_text()
|
|
366
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
367
|
+
except json.JSONDecodeError as exc:
|
|
368
|
+
logger.warning(
|
|
369
|
+
"[GoogleChat] thread-count store at %s is corrupt; "
|
|
370
|
+
"starting fresh: %s",
|
|
371
|
+
self._path, exc,
|
|
372
|
+
)
|
|
373
|
+
self._counts = {}
|
|
374
|
+
return
|
|
375
|
+
except OSError as exc:
|
|
376
|
+
logger.warning(
|
|
377
|
+
"[GoogleChat] could not read thread-count store at %s: %s",
|
|
378
|
+
self._path, exc,
|
|
379
|
+
)
|
|
380
|
+
self._counts = {}
|
|
381
|
+
return
|
|
382
|
+
# Validate shape — anything off-schema gets dropped silently.
|
|
383
|
+
clean: Dict[str, Dict[str, int]] = {}
|
|
384
|
+
if isinstance(data, dict):
|
|
385
|
+
for chat_id, threads in data.items():
|
|
386
|
+
if not isinstance(chat_id, str) or not isinstance(threads, dict):
|
|
387
|
+
continue
|
|
388
|
+
clean_threads: Dict[str, int] = {}
|
|
389
|
+
for thread_name, count in threads.items():
|
|
390
|
+
if isinstance(thread_name, str) and isinstance(count, int):
|
|
391
|
+
clean_threads[thread_name] = count
|
|
392
|
+
if clean_threads:
|
|
393
|
+
clean[chat_id] = clean_threads
|
|
394
|
+
self._counts = clean
|
|
395
|
+
|
|
396
|
+
def get(self, chat_id: str, thread_name: str) -> int:
|
|
397
|
+
"""Return the current count for (chat_id, thread_name), or 0."""
|
|
398
|
+
return self._counts.get(chat_id, {}).get(thread_name, 0)
|
|
399
|
+
|
|
400
|
+
def incr(self, chat_id: str, thread_name: str) -> int:
|
|
401
|
+
"""Increment count and write through to disk. Returns the
|
|
402
|
+
PRE-increment value (the heuristic input — "have we seen this
|
|
403
|
+
thread before this message?")."""
|
|
404
|
+
chat_counts = self._counts.setdefault(chat_id, {})
|
|
405
|
+
prev = chat_counts.get(thread_name, 0)
|
|
406
|
+
chat_counts[thread_name] = prev + 1
|
|
407
|
+
self._save()
|
|
408
|
+
return prev
|
|
409
|
+
|
|
410
|
+
def _save(self) -> None:
|
|
411
|
+
"""Atomic write of the counts dict to disk.
|
|
412
|
+
|
|
413
|
+
Failure is non-fatal — log warning and continue. The in-memory
|
|
414
|
+
counts stay consistent within the running process; only restart
|
|
415
|
+
recovery is affected.
|
|
416
|
+
"""
|
|
417
|
+
try:
|
|
418
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
|
420
|
+
tmp.write_text(json.dumps(self._counts, separators=(",", ":")))
|
|
421
|
+
os.replace(tmp, self._path)
|
|
422
|
+
except OSError as exc:
|
|
423
|
+
logger.warning(
|
|
424
|
+
"[GoogleChat] could not persist thread-count store to %s: %s",
|
|
425
|
+
self._path, exc,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class GoogleChatAdapter(BasePlatformAdapter):
|
|
430
|
+
"""
|
|
431
|
+
Google Chat bot adapter using Pub/Sub pull + Chat REST API.
|
|
432
|
+
|
|
433
|
+
Required environment (see gateway/config.py Google Chat block):
|
|
434
|
+
GOOGLE_CHAT_PROJECT_ID (or GOOGLE_CLOUD_PROJECT fallback)
|
|
435
|
+
GOOGLE_CHAT_SUBSCRIPTION_NAME (or GOOGLE_CHAT_SUBSCRIPTION fallback)
|
|
436
|
+
GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (or GOOGLE_APPLICATION_CREDENTIALS)
|
|
437
|
+
|
|
438
|
+
Optional:
|
|
439
|
+
GOOGLE_CHAT_ALLOWED_USERS, GOOGLE_CHAT_ALLOW_ALL_USERS
|
|
440
|
+
GOOGLE_CHAT_HOME_CHANNEL
|
|
441
|
+
GOOGLE_CHAT_MAX_MESSAGES (FlowControl, default 1)
|
|
442
|
+
GOOGLE_CHAT_MAX_BYTES (FlowControl, default 16_777_216 = 16 MiB)
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
MAX_MESSAGE_LENGTH = _MAX_TEXT_LENGTH
|
|
446
|
+
# Pub/Sub supervisor configuration.
|
|
447
|
+
_MAX_RECONNECT_ATTEMPTS = 10
|
|
448
|
+
_RECONNECT_BASE_DELAY = 2.0
|
|
449
|
+
_RECONNECT_MAX_DELAY = 120.0
|
|
450
|
+
|
|
451
|
+
def __init__(self, config: PlatformConfig):
|
|
452
|
+
# ``Platform("google_chat")`` resolves via ``_missing_()`` → pseudo-member
|
|
453
|
+
# cached in ``_value2member_map_``. We deliberately do NOT add an enum
|
|
454
|
+
# attribute to ``gateway.config.Platform`` — bundled platform plugins
|
|
455
|
+
# are looked up by value, not attribute (matches Teams, IRC).
|
|
456
|
+
super().__init__(config, Platform("google_chat"))
|
|
457
|
+
# Trigger the deferred google-cloud + googleapiclient import here so
|
|
458
|
+
# that any code path which constructs the adapter and then calls
|
|
459
|
+
# methods directly (notably the test suite, which builds an adapter
|
|
460
|
+
# and invokes ``_send_file`` / ``_create_message`` / etc. without
|
|
461
|
+
# going through ``connect()``) sees real classes for ``MediaFileUpload``,
|
|
462
|
+
# ``service_account``, ``HttpError``, and friends. The module-level
|
|
463
|
+
# globals were previously eager-imported; making this lazy saved
|
|
464
|
+
# ~110ms / ~33MB on every CLI invocation. Idempotent — pays the cost
|
|
465
|
+
# exactly once per process.
|
|
466
|
+
_load_google_modules()
|
|
467
|
+
self._subscriber: Optional[Any] = None
|
|
468
|
+
self._chat_api: Optional[Any] = None
|
|
469
|
+
# User-authed Chat API client built lazily from the OAuth refresh
|
|
470
|
+
# token persisted by the plugin's ``oauth.py`` helper. Required for
|
|
471
|
+
# native ``media.upload`` (bot identity is rejected by that
|
|
472
|
+
# endpoint).
|
|
473
|
+
#
|
|
474
|
+
# Multi-user mode: each user runs ``/setup-files`` ONCE in their
|
|
475
|
+
# own DM and the resulting refresh token is stored under their
|
|
476
|
+
# email. ``_send_file`` looks up the requesting user's email via
|
|
477
|
+
# ``_last_sender_by_chat`` and uses THAT user's token, so when
|
|
478
|
+
# User B asks for a file in B's DM the bot uploads as B (not as
|
|
479
|
+
# whoever first set up files long ago).
|
|
480
|
+
#
|
|
481
|
+
# ``_user_credentials`` / ``_user_chat_api`` keep their old names
|
|
482
|
+
# but now hold the LEGACY single-user token (if any) — used as a
|
|
483
|
+
# last-ditch fallback when the requesting user has no per-user
|
|
484
|
+
# token yet. Pre-multi-user installs continue to work unchanged.
|
|
485
|
+
self._user_chat_api: Optional[Any] = None
|
|
486
|
+
self._user_credentials: Optional[Any] = None
|
|
487
|
+
# Per-email caches. Populated lazily by ``_get_user_chat_for_chat``.
|
|
488
|
+
self._user_creds_by_email: Dict[str, Any] = {}
|
|
489
|
+
self._user_chat_api_by_email: Dict[str, Any] = {}
|
|
490
|
+
# chat_id → most-recent inbound sender's email. Populated in
|
|
491
|
+
# ``_build_message_event`` whenever the inbound event carries a
|
|
492
|
+
# non-empty ``sender.email``. Drives the per-user token lookup
|
|
493
|
+
# in ``_send_file`` so the bot uploads as the user who triggered
|
|
494
|
+
# the request, not as some other authorized user.
|
|
495
|
+
self._last_sender_by_chat: Dict[str, str] = {}
|
|
496
|
+
self._credentials: Optional[Any] = None
|
|
497
|
+
self._project_id: Optional[str] = None
|
|
498
|
+
self._subscription_path: Optional[str] = None
|
|
499
|
+
self._streaming_pull_future: Optional[Any] = None
|
|
500
|
+
self._supervisor_task: Optional[asyncio.Task] = None
|
|
501
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
502
|
+
self._bot_user_id: Optional[str] = None # users/{id}
|
|
503
|
+
self._dedup = MessageDeduplicator()
|
|
504
|
+
self._typing_messages: Dict[str, str] = {}
|
|
505
|
+
self._shutting_down = False
|
|
506
|
+
self._rate_limit_hits: Dict[str, int] = {}
|
|
507
|
+
# Last-seen inbound thread name per chat_id (space). Google Chat
|
|
508
|
+
# DMs create a NEW thread per top-level user message but the user
|
|
509
|
+
# views them as one logical conversation. We:
|
|
510
|
+
# (a) drop thread_id from the source for DMs (so session_key
|
|
511
|
+
# stays stable across top-level messages — see
|
|
512
|
+
# gateway/session.py:build_session_key).
|
|
513
|
+
# (b) cache the most recent inbound thread name here so outbound
|
|
514
|
+
# replies still land in the right visual thread without
|
|
515
|
+
# re-coupling sessions to threads.
|
|
516
|
+
self._last_inbound_thread: Dict[str, str] = {}
|
|
517
|
+
# Inbound message count per (chat_id, thread_name). Drives the
|
|
518
|
+
# DM main-flow vs side-thread heuristic in _build_message_event
|
|
519
|
+
# and the outbound thread routing in _resolve_thread_id.
|
|
520
|
+
# Persisted to ${HERMES_HOME}/google_chat_thread_counts.json so
|
|
521
|
+
# active side-threads survive gateway restarts (the bug that
|
|
522
|
+
# made the in-memory version of this heuristic flaky for
|
|
523
|
+
# multi-restart sessions).
|
|
524
|
+
try:
|
|
525
|
+
from calvyn_constants import get_hermes_home as _get_hermes_home
|
|
526
|
+
_hermes_home = _get_hermes_home()
|
|
527
|
+
except (ModuleNotFoundError, ImportError):
|
|
528
|
+
_hermes_home = _Path.home() / ".hermes"
|
|
529
|
+
self._thread_count_store = _ThreadCountStore(
|
|
530
|
+
_hermes_home / "google_chat_thread_counts.json"
|
|
531
|
+
)
|
|
532
|
+
# In-flight typing-card creates per chat_id. send_typing() reserves
|
|
533
|
+
# an Event here BEFORE starting the API call so concurrent calls
|
|
534
|
+
# from base.py's _keep_typing wait instead of duplicating cards.
|
|
535
|
+
# Cleared in the create_and_record finally.
|
|
536
|
+
self._typing_card_inflight: Dict[str, asyncio.Event] = {}
|
|
537
|
+
# Orphaned typing cards (created by background tasks that lost a
|
|
538
|
+
# race with send() / another concurrent create). Cleaned up at
|
|
539
|
+
# end-of-turn by on_processing_complete via patch-to-empty so
|
|
540
|
+
# they don't sit in the chat forever as "Hermes is thinking…".
|
|
541
|
+
self._orphan_typing_messages: Dict[str, List[str]] = {}
|
|
542
|
+
# FlowControl knobs (env-configurable).
|
|
543
|
+
self._max_messages = int(os.getenv("GOOGLE_CHAT_MAX_MESSAGES", "1"))
|
|
544
|
+
self._max_bytes = int(os.getenv("GOOGLE_CHAT_MAX_BYTES", str(16 * 1024 * 1024)))
|
|
545
|
+
|
|
546
|
+
# ------------------------------------------------------------------
|
|
547
|
+
# Configuration loading and validation
|
|
548
|
+
# ------------------------------------------------------------------
|
|
549
|
+
def _load_sa_credentials(self) -> Any:
|
|
550
|
+
"""Load Service Account credentials from env or config.extra,
|
|
551
|
+
falling back to Application Default Credentials.
|
|
552
|
+
|
|
553
|
+
Priority:
|
|
554
|
+
1. Explicit ``extra['service_account_json']`` (path or inline JSON)
|
|
555
|
+
2. ``GOOGLE_APPLICATION_CREDENTIALS`` env var (path)
|
|
556
|
+
3. Application Default Credentials via ``google.auth.default()``
|
|
557
|
+
— works on Cloud Run / GCE / GKE with a workload identity
|
|
558
|
+
attached, or locally via ``gcloud auth application-default
|
|
559
|
+
login``. Lets operators run the gateway in GCP without
|
|
560
|
+
managing SA key files. Pattern lifted from PR #14965.
|
|
561
|
+
"""
|
|
562
|
+
sa_path = (
|
|
563
|
+
self.config.extra.get("service_account_json")
|
|
564
|
+
or os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
|
565
|
+
)
|
|
566
|
+
if sa_path:
|
|
567
|
+
# Inline JSON (rare, but supported).
|
|
568
|
+
if sa_path.lstrip().startswith("{"):
|
|
569
|
+
try:
|
|
570
|
+
info = json.loads(sa_path)
|
|
571
|
+
except json.JSONDecodeError as exc:
|
|
572
|
+
raise ValueError(
|
|
573
|
+
f"Inline SA JSON is not valid JSON: {exc}"
|
|
574
|
+
) from exc
|
|
575
|
+
return service_account.Credentials.from_service_account_info(
|
|
576
|
+
info, scopes=_CHAT_SCOPES
|
|
577
|
+
)
|
|
578
|
+
if not os.path.exists(sa_path):
|
|
579
|
+
raise FileNotFoundError(
|
|
580
|
+
f"Service Account JSON file not found at configured path."
|
|
581
|
+
)
|
|
582
|
+
# Validate file parses before handing to google-auth for nicer error.
|
|
583
|
+
try:
|
|
584
|
+
with open(sa_path, "r", encoding="utf-8") as fh:
|
|
585
|
+
info = json.load(fh)
|
|
586
|
+
except json.JSONDecodeError as exc:
|
|
587
|
+
raise ValueError(
|
|
588
|
+
f"Service Account JSON file is not valid JSON: {exc}"
|
|
589
|
+
) from exc
|
|
590
|
+
return service_account.Credentials.from_service_account_info(
|
|
591
|
+
info, scopes=_CHAT_SCOPES
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# No explicit SA configured — try ADC. This is the Cloud Run / GCE
|
|
595
|
+
# path; google-auth picks up the workload identity automatically.
|
|
596
|
+
try:
|
|
597
|
+
import google.auth as google_auth
|
|
598
|
+
except ImportError:
|
|
599
|
+
google_auth = None # type: ignore[assignment]
|
|
600
|
+
if google_auth is None:
|
|
601
|
+
raise ValueError(
|
|
602
|
+
"No Service Account credentials configured. Set "
|
|
603
|
+
"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or GOOGLE_APPLICATION_CREDENTIALS, "
|
|
604
|
+
"or install google-auth to use Application Default Credentials."
|
|
605
|
+
)
|
|
606
|
+
try:
|
|
607
|
+
credentials, _project = google_auth.default(scopes=_CHAT_SCOPES)
|
|
608
|
+
except Exception as exc:
|
|
609
|
+
raise ValueError(
|
|
610
|
+
"No Service Account credentials configured and Application "
|
|
611
|
+
"Default Credentials are unavailable. Set "
|
|
612
|
+
"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or run "
|
|
613
|
+
"``gcloud auth application-default login``. "
|
|
614
|
+
f"ADC error: {exc}"
|
|
615
|
+
) from exc
|
|
616
|
+
logger.info(
|
|
617
|
+
"[GoogleChat] No SA JSON configured; using Application "
|
|
618
|
+
"Default Credentials"
|
|
619
|
+
)
|
|
620
|
+
return credentials
|
|
621
|
+
|
|
622
|
+
def _validate_config(self) -> Tuple[str, str]:
|
|
623
|
+
"""Return (project_id, subscription_path) after validation.
|
|
624
|
+
|
|
625
|
+
Raises ValueError with a sanitized message on any config problem.
|
|
626
|
+
"""
|
|
627
|
+
project_id = self.config.extra.get("project_id")
|
|
628
|
+
subscription = self.config.extra.get("subscription_name")
|
|
629
|
+
if not project_id:
|
|
630
|
+
raise ValueError(
|
|
631
|
+
"GOOGLE_CHAT_PROJECT_ID (or GOOGLE_CLOUD_PROJECT) is not set."
|
|
632
|
+
)
|
|
633
|
+
if not subscription:
|
|
634
|
+
raise ValueError(
|
|
635
|
+
"GOOGLE_CHAT_SUBSCRIPTION_NAME (or GOOGLE_CHAT_SUBSCRIPTION) is not set."
|
|
636
|
+
)
|
|
637
|
+
match = _SUBSCRIPTION_PATH_RE.match(subscription)
|
|
638
|
+
if not match:
|
|
639
|
+
raise ValueError(
|
|
640
|
+
"GOOGLE_CHAT_SUBSCRIPTION_NAME must match "
|
|
641
|
+
"'projects/<project>/subscriptions/<sub>'."
|
|
642
|
+
)
|
|
643
|
+
if match.group("project") != project_id:
|
|
644
|
+
raise ValueError(
|
|
645
|
+
"project_id in GOOGLE_CHAT_PROJECT_ID does not match the "
|
|
646
|
+
"project embedded in GOOGLE_CHAT_SUBSCRIPTION_NAME."
|
|
647
|
+
)
|
|
648
|
+
return project_id, subscription
|
|
649
|
+
|
|
650
|
+
# ------------------------------------------------------------------
|
|
651
|
+
# Loop bridge helpers (thread -> asyncio loop)
|
|
652
|
+
# ------------------------------------------------------------------
|
|
653
|
+
@staticmethod
|
|
654
|
+
def _log_background_failure(future: Any) -> None:
|
|
655
|
+
try:
|
|
656
|
+
future.result()
|
|
657
|
+
except Exception:
|
|
658
|
+
logger.exception("[GoogleChat] Background inbound processing failed")
|
|
659
|
+
|
|
660
|
+
@staticmethod
|
|
661
|
+
def _loop_accepts_callbacks(loop: Optional[asyncio.AbstractEventLoop]) -> bool:
|
|
662
|
+
return loop is not None and not bool(getattr(loop, "is_closed", lambda: False)())
|
|
663
|
+
|
|
664
|
+
def _submit_on_loop(self, coro: Any) -> None:
|
|
665
|
+
"""Schedule a coroutine on the adapter loop from a Pub/Sub callback thread."""
|
|
666
|
+
loop = self._loop
|
|
667
|
+
if not self._loop_accepts_callbacks(loop):
|
|
668
|
+
# Loop already closed (shutdown race). Safe to drop; Pub/Sub will
|
|
669
|
+
# redeliver on next reconnect.
|
|
670
|
+
logger.warning("[GoogleChat] Loop not accepting callbacks; dropping event")
|
|
671
|
+
return
|
|
672
|
+
try:
|
|
673
|
+
from agent.async_utils import safe_schedule_threadsafe
|
|
674
|
+
future = safe_schedule_threadsafe(
|
|
675
|
+
coro, loop,
|
|
676
|
+
logger=logger,
|
|
677
|
+
log_message="[GoogleChat] Failed to schedule background callback",
|
|
678
|
+
log_level=logging.WARNING,
|
|
679
|
+
)
|
|
680
|
+
except RuntimeError:
|
|
681
|
+
logger.warning("[GoogleChat] Loop closed between check and submit")
|
|
682
|
+
return
|
|
683
|
+
if future is None:
|
|
684
|
+
return
|
|
685
|
+
future.add_done_callback(self._log_background_failure)
|
|
686
|
+
|
|
687
|
+
# ------------------------------------------------------------------
|
|
688
|
+
# Bot identity resolution
|
|
689
|
+
# ------------------------------------------------------------------
|
|
690
|
+
def _bot_id_cache_path(self) -> _Path:
|
|
691
|
+
"""Location where the resolved bot user_id is cached across restarts."""
|
|
692
|
+
base = os.getenv("HERMES_HOME", str(_Path.home() / ".hermes"))
|
|
693
|
+
return _Path(base) / "google_chat_bot_id.json"
|
|
694
|
+
|
|
695
|
+
def _load_cached_bot_id(self) -> Optional[str]:
|
|
696
|
+
path = self._bot_id_cache_path()
|
|
697
|
+
if not path.exists():
|
|
698
|
+
return None
|
|
699
|
+
try:
|
|
700
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
701
|
+
return data.get("bot_user_id") or None
|
|
702
|
+
except (OSError, json.JSONDecodeError):
|
|
703
|
+
return None
|
|
704
|
+
|
|
705
|
+
def _save_cached_bot_id(self, bot_user_id: str) -> None:
|
|
706
|
+
try:
|
|
707
|
+
path = self._bot_id_cache_path()
|
|
708
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
709
|
+
path.write_text(
|
|
710
|
+
json.dumps({"bot_user_id": bot_user_id}),
|
|
711
|
+
encoding="utf-8",
|
|
712
|
+
)
|
|
713
|
+
except OSError:
|
|
714
|
+
logger.debug("[GoogleChat] Could not persist bot_user_id cache", exc_info=True)
|
|
715
|
+
|
|
716
|
+
async def _resolve_bot_user_id(self) -> Optional[str]:
|
|
717
|
+
"""Resolve ``users/{id}`` via Chat API members.list on a known space.
|
|
718
|
+
|
|
719
|
+
Tries the home channel first, then any space from the allowlist.
|
|
720
|
+
If no space is known, returns None and self-filter falls back to
|
|
721
|
+
filtering ``sender.type == 'BOT'`` (which is still safe but less
|
|
722
|
+
precise — own messages and other bots look alike).
|
|
723
|
+
"""
|
|
724
|
+
candidate_spaces: List[str] = []
|
|
725
|
+
if self.config.home_channel and self.config.home_channel.chat_id:
|
|
726
|
+
candidate_spaces.append(self.config.home_channel.chat_id)
|
|
727
|
+
# Env-configured allowed spaces (comma-separated). Optional.
|
|
728
|
+
extra_spaces = os.getenv("GOOGLE_CHAT_BOOTSTRAP_SPACES", "").strip()
|
|
729
|
+
if extra_spaces:
|
|
730
|
+
candidate_spaces.extend(
|
|
731
|
+
s.strip() for s in extra_spaces.split(",") if s.strip()
|
|
732
|
+
)
|
|
733
|
+
for space in candidate_spaces:
|
|
734
|
+
try:
|
|
735
|
+
members = await asyncio.to_thread(
|
|
736
|
+
lambda s=space: self._chat_api.spaces()
|
|
737
|
+
.members()
|
|
738
|
+
.list(parent=s, pageSize=50)
|
|
739
|
+
.execute(http=self._new_authed_http())
|
|
740
|
+
)
|
|
741
|
+
except HttpError as exc:
|
|
742
|
+
logger.debug(
|
|
743
|
+
"[GoogleChat] members.list failed on %s: %s",
|
|
744
|
+
space,
|
|
745
|
+
_redact_sensitive(str(exc)),
|
|
746
|
+
)
|
|
747
|
+
continue
|
|
748
|
+
for member in members.get("memberships", []):
|
|
749
|
+
if member.get("member", {}).get("type") == "BOT":
|
|
750
|
+
name = member.get("member", {}).get("name")
|
|
751
|
+
if name:
|
|
752
|
+
return name
|
|
753
|
+
return None
|
|
754
|
+
|
|
755
|
+
# ------------------------------------------------------------------
|
|
756
|
+
# Connection lifecycle
|
|
757
|
+
# ------------------------------------------------------------------
|
|
758
|
+
async def connect(self) -> bool:
|
|
759
|
+
"""Validate config, authenticate, start Pub/Sub pull, resolve bot id."""
|
|
760
|
+
# First call into the heavy google-cloud stack — trigger the lazy
|
|
761
|
+
# import. ``_load_google_modules()`` is idempotent and rebinds the
|
|
762
|
+
# module globals (``pubsub_v1``, ``service_account``, ``HttpError``,
|
|
763
|
+
# …) used throughout this file. Anything that runs *before* this
|
|
764
|
+
# call would see the placeholders, so connect() is the natural
|
|
765
|
+
# gate.
|
|
766
|
+
if not _load_google_modules():
|
|
767
|
+
self._set_fatal_error(
|
|
768
|
+
code="missing_deps",
|
|
769
|
+
message="google-cloud-pubsub / google-api-python-client not installed",
|
|
770
|
+
retryable=False,
|
|
771
|
+
)
|
|
772
|
+
return False
|
|
773
|
+
|
|
774
|
+
self._loop = asyncio.get_running_loop()
|
|
775
|
+
try:
|
|
776
|
+
project_id, subscription_path = self._validate_config()
|
|
777
|
+
credentials = self._load_sa_credentials()
|
|
778
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
779
|
+
msg = _redact_sensitive(str(exc))
|
|
780
|
+
logger.error("[GoogleChat] Config validation failed: %s", msg)
|
|
781
|
+
self._set_fatal_error(code="config_invalid", message=msg, retryable=False)
|
|
782
|
+
return False
|
|
783
|
+
|
|
784
|
+
self._project_id = project_id
|
|
785
|
+
self._subscription_path = subscription_path
|
|
786
|
+
self._credentials = credentials
|
|
787
|
+
|
|
788
|
+
# Build Chat REST client (sync; wrap calls in asyncio.to_thread).
|
|
789
|
+
try:
|
|
790
|
+
self._chat_api = await asyncio.to_thread(
|
|
791
|
+
lambda: build_service(
|
|
792
|
+
"chat",
|
|
793
|
+
"v1",
|
|
794
|
+
credentials=credentials,
|
|
795
|
+
cache_discovery=False,
|
|
796
|
+
)
|
|
797
|
+
)
|
|
798
|
+
except Exception as exc:
|
|
799
|
+
msg = _redact_sensitive(str(exc))
|
|
800
|
+
logger.error("[GoogleChat] Failed to build Chat API client: %s", msg)
|
|
801
|
+
self._set_fatal_error(code="chat_api_init", message=msg, retryable=False)
|
|
802
|
+
return False
|
|
803
|
+
|
|
804
|
+
# Attempt to load LEGACY single-user OAuth credentials at startup.
|
|
805
|
+
# In multi-user mode each user's token is loaded lazily by
|
|
806
|
+
# ``_load_per_user_chat_api`` on first send. The legacy slot is
|
|
807
|
+
# kept as a last-ditch fallback for pre-multi-user installs and
|
|
808
|
+
# for groups where the asker has no per-user token yet. Failure
|
|
809
|
+
# here is NON-fatal: text messaging continues to work; only
|
|
810
|
+
# attachments degrade to a setup-instructions text notice.
|
|
811
|
+
try:
|
|
812
|
+
from .oauth import (
|
|
813
|
+
load_user_credentials as _load_user_creds,
|
|
814
|
+
build_user_chat_service as _build_user_chat,
|
|
815
|
+
list_authorized_emails as _list_emails,
|
|
816
|
+
)
|
|
817
|
+
user_creds = await asyncio.to_thread(_load_user_creds)
|
|
818
|
+
if user_creds is not None:
|
|
819
|
+
self._user_credentials = user_creds
|
|
820
|
+
self._user_chat_api = await asyncio.to_thread(
|
|
821
|
+
lambda: _build_user_chat(user_creds)
|
|
822
|
+
)
|
|
823
|
+
logger.info(
|
|
824
|
+
"[GoogleChat] Legacy user OAuth loaded — fallback "
|
|
825
|
+
"attachment delivery enabled"
|
|
826
|
+
)
|
|
827
|
+
authorized = await asyncio.to_thread(_list_emails)
|
|
828
|
+
if authorized:
|
|
829
|
+
logger.info(
|
|
830
|
+
"[GoogleChat] %d per-user OAuth tokens on disk: %s",
|
|
831
|
+
len(authorized), ", ".join(authorized),
|
|
832
|
+
)
|
|
833
|
+
elif user_creds is None:
|
|
834
|
+
logger.info(
|
|
835
|
+
"[GoogleChat] No user OAuth tokens at setup — file "
|
|
836
|
+
"attachments will degrade to text-only fallback. "
|
|
837
|
+
"Each user runs /setup-files once in their own DM "
|
|
838
|
+
"to enable native attachments."
|
|
839
|
+
)
|
|
840
|
+
except Exception as exc:
|
|
841
|
+
logger.warning(
|
|
842
|
+
"[GoogleChat] User OAuth load failed (attachments will "
|
|
843
|
+
"degrade to text-only fallback): %s",
|
|
844
|
+
_redact_sensitive(str(exc)),
|
|
845
|
+
)
|
|
846
|
+
self._user_credentials = None
|
|
847
|
+
self._user_chat_api = None
|
|
848
|
+
|
|
849
|
+
# Load the persistent thread-count store so the side-thread
|
|
850
|
+
# heuristic in _build_message_event survives gateway restarts.
|
|
851
|
+
try:
|
|
852
|
+
await asyncio.to_thread(self._thread_count_store.load)
|
|
853
|
+
except Exception:
|
|
854
|
+
logger.warning(
|
|
855
|
+
"[GoogleChat] thread-count store load failed (treating "
|
|
856
|
+
"all threads as fresh)", exc_info=True,
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
# Sanity check: subscription exists / SA has access.
|
|
860
|
+
self._subscriber = pubsub_v1.SubscriberClient(credentials=credentials)
|
|
861
|
+
try:
|
|
862
|
+
await asyncio.to_thread(
|
|
863
|
+
lambda: self._subscriber.get_subscription(
|
|
864
|
+
request={"subscription": subscription_path}
|
|
865
|
+
)
|
|
866
|
+
)
|
|
867
|
+
except gax_exceptions.NotFound:
|
|
868
|
+
self._set_fatal_error(
|
|
869
|
+
code="subscription_not_found",
|
|
870
|
+
message="Pub/Sub subscription not found at configured path",
|
|
871
|
+
retryable=False,
|
|
872
|
+
)
|
|
873
|
+
return False
|
|
874
|
+
except gax_exceptions.PermissionDenied:
|
|
875
|
+
self._set_fatal_error(
|
|
876
|
+
code="subscription_permission",
|
|
877
|
+
message=(
|
|
878
|
+
"Service Account lacks roles/pubsub.subscriber on the "
|
|
879
|
+
"subscription"
|
|
880
|
+
),
|
|
881
|
+
retryable=False,
|
|
882
|
+
)
|
|
883
|
+
return False
|
|
884
|
+
except Exception as exc:
|
|
885
|
+
msg = _redact_sensitive(str(exc))
|
|
886
|
+
logger.error("[GoogleChat] subscription.get failed: %s", msg)
|
|
887
|
+
self._set_fatal_error(code="subscription_check", message=msg, retryable=True)
|
|
888
|
+
return False
|
|
889
|
+
|
|
890
|
+
# Resolve bot user_id (eager): cache first, then members.list.
|
|
891
|
+
self._bot_user_id = self._load_cached_bot_id()
|
|
892
|
+
if not self._bot_user_id:
|
|
893
|
+
self._bot_user_id = await self._resolve_bot_user_id()
|
|
894
|
+
if self._bot_user_id:
|
|
895
|
+
self._save_cached_bot_id(self._bot_user_id)
|
|
896
|
+
else:
|
|
897
|
+
logger.info(
|
|
898
|
+
"[GoogleChat] bot_user_id not yet resolved; "
|
|
899
|
+
"will resolve on first addedToSpace or member lookup"
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
# Start the supervisor task that runs the Pub/Sub pull with exponential
|
|
903
|
+
# backoff + jitter on transient errors, bails out after N retries.
|
|
904
|
+
self._supervisor_task = asyncio.create_task(self._run_supervisor())
|
|
905
|
+
self._mark_connected()
|
|
906
|
+
logger.info(
|
|
907
|
+
"[GoogleChat] Connected; project=%s, subscription=<redacted>, "
|
|
908
|
+
"bot_user_id=%s, flow_control(msgs=%s, bytes=%s)",
|
|
909
|
+
project_id,
|
|
910
|
+
self._bot_user_id or "<unresolved>",
|
|
911
|
+
self._max_messages,
|
|
912
|
+
self._max_bytes,
|
|
913
|
+
)
|
|
914
|
+
return True
|
|
915
|
+
|
|
916
|
+
async def disconnect(self) -> None:
|
|
917
|
+
"""Clean shutdown: stop accepting new messages, wait in-flight, close clients."""
|
|
918
|
+
self._shutting_down = True
|
|
919
|
+
if self._supervisor_task and not self._supervisor_task.done():
|
|
920
|
+
self._supervisor_task.cancel()
|
|
921
|
+
try:
|
|
922
|
+
await asyncio.wait_for(self._supervisor_task, timeout=5.0)
|
|
923
|
+
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
924
|
+
pass
|
|
925
|
+
if self._streaming_pull_future is not None:
|
|
926
|
+
try:
|
|
927
|
+
self._streaming_pull_future.cancel()
|
|
928
|
+
await asyncio.to_thread(self._streaming_pull_future.result, 10.0)
|
|
929
|
+
except Exception:
|
|
930
|
+
pass
|
|
931
|
+
self._streaming_pull_future = None
|
|
932
|
+
if self._subscriber is not None:
|
|
933
|
+
try:
|
|
934
|
+
await asyncio.to_thread(self._subscriber.close)
|
|
935
|
+
except Exception:
|
|
936
|
+
pass
|
|
937
|
+
self._subscriber = None
|
|
938
|
+
self._mark_disconnected()
|
|
939
|
+
logger.info("[GoogleChat] Disconnected")
|
|
940
|
+
|
|
941
|
+
# ------------------------------------------------------------------
|
|
942
|
+
# Pub/Sub supervisor (reconnect loop)
|
|
943
|
+
# ------------------------------------------------------------------
|
|
944
|
+
async def _run_supervisor(self) -> None:
|
|
945
|
+
"""Run the streaming_pull with exponential backoff; fatal after 10 attempts.
|
|
946
|
+
|
|
947
|
+
``subscribe()`` returns a concurrent.futures.Future that resolves when
|
|
948
|
+
the stream dies. We await ``future.result()`` in a worker thread and
|
|
949
|
+
react to exceptions.
|
|
950
|
+
"""
|
|
951
|
+
attempt = 0
|
|
952
|
+
while not self._shutting_down:
|
|
953
|
+
flow = pubsub_v1.types.FlowControl(
|
|
954
|
+
max_messages=self._max_messages,
|
|
955
|
+
max_bytes=self._max_bytes,
|
|
956
|
+
)
|
|
957
|
+
try:
|
|
958
|
+
future = self._subscriber.subscribe(
|
|
959
|
+
self._subscription_path,
|
|
960
|
+
callback=self._on_pubsub_message,
|
|
961
|
+
flow_control=flow,
|
|
962
|
+
)
|
|
963
|
+
self._streaming_pull_future = future
|
|
964
|
+
if attempt > 0:
|
|
965
|
+
logger.info("[GoogleChat] Pub/Sub stream reconnected after %d attempts", attempt)
|
|
966
|
+
attempt = 0
|
|
967
|
+
# Blocks until stream dies or cancel().
|
|
968
|
+
await asyncio.to_thread(future.result)
|
|
969
|
+
# Normal completion = disconnect requested.
|
|
970
|
+
if self._shutting_down:
|
|
971
|
+
return
|
|
972
|
+
except asyncio.CancelledError:
|
|
973
|
+
return
|
|
974
|
+
except gax_exceptions.Unauthenticated:
|
|
975
|
+
self._set_fatal_error(
|
|
976
|
+
code="pubsub_auth",
|
|
977
|
+
message="Pub/Sub authentication failed (SA key invalid/revoked)",
|
|
978
|
+
retryable=False,
|
|
979
|
+
)
|
|
980
|
+
return
|
|
981
|
+
except gax_exceptions.PermissionDenied:
|
|
982
|
+
self._set_fatal_error(
|
|
983
|
+
code="pubsub_permission",
|
|
984
|
+
message="SA lacks pubsub.subscriber on the subscription",
|
|
985
|
+
retryable=False,
|
|
986
|
+
)
|
|
987
|
+
return
|
|
988
|
+
except Exception as exc:
|
|
989
|
+
attempt += 1
|
|
990
|
+
msg = _redact_sensitive(str(exc))
|
|
991
|
+
logger.warning(
|
|
992
|
+
"[GoogleChat] Pub/Sub stream died (attempt %d/%d): %s",
|
|
993
|
+
attempt,
|
|
994
|
+
self._MAX_RECONNECT_ATTEMPTS,
|
|
995
|
+
msg,
|
|
996
|
+
)
|
|
997
|
+
if attempt >= self._MAX_RECONNECT_ATTEMPTS:
|
|
998
|
+
self._set_fatal_error(
|
|
999
|
+
code="pubsub_reconnect_exhausted",
|
|
1000
|
+
message=f"Pub/Sub reconnect failed {attempt} times; giving up",
|
|
1001
|
+
retryable=False,
|
|
1002
|
+
)
|
|
1003
|
+
return
|
|
1004
|
+
delay = min(
|
|
1005
|
+
self._RECONNECT_MAX_DELAY,
|
|
1006
|
+
self._RECONNECT_BASE_DELAY * (2 ** (attempt - 1)),
|
|
1007
|
+
)
|
|
1008
|
+
# Full jitter: pick uniformly in [0, delay].
|
|
1009
|
+
sleep_for = random.uniform(0, delay)
|
|
1010
|
+
try:
|
|
1011
|
+
await asyncio.sleep(sleep_for)
|
|
1012
|
+
except asyncio.CancelledError:
|
|
1013
|
+
return
|
|
1014
|
+
|
|
1015
|
+
# ------------------------------------------------------------------
|
|
1016
|
+
# Inbound event handling (Pub/Sub callback runs in a thread)
|
|
1017
|
+
# ------------------------------------------------------------------
|
|
1018
|
+
@staticmethod
|
|
1019
|
+
def _extract_message_payload(
|
|
1020
|
+
envelope: Dict[str, Any], ce_type: str = ""
|
|
1021
|
+
) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], str]]:
|
|
1022
|
+
"""Detect Pub/Sub envelope format and return ``(message, space, format_name)``.
|
|
1023
|
+
|
|
1024
|
+
Three known formats are accepted. Returns ``None`` when the envelope
|
|
1025
|
+
is unrecognized, is a non-MESSAGE event, or otherwise should be
|
|
1026
|
+
silently dropped.
|
|
1027
|
+
|
|
1028
|
+
Format 1 — Workspace Add-ons (canonical, ce-type-driven)::
|
|
1029
|
+
|
|
1030
|
+
{"chat": {"messagePayload": {"message": {...}, "space": {...}}}}
|
|
1031
|
+
|
|
1032
|
+
Format 2 — Native Chat API Pub/Sub (alternative configuration where
|
|
1033
|
+
the Chat app publishes events directly without the Workspace
|
|
1034
|
+
Add-ons wrapper)::
|
|
1035
|
+
|
|
1036
|
+
{"type": "MESSAGE", "message": {...}, "space": {...}}
|
|
1037
|
+
|
|
1038
|
+
Format 3 — Relay / flat (a custom Cloud Run relay that flattens the
|
|
1039
|
+
Chat event into top-level fields)::
|
|
1040
|
+
|
|
1041
|
+
{"event_type": "MESSAGE", "sender_email": "...", "text": "...",
|
|
1042
|
+
"space_name": "spaces/X", "thread_name": "spaces/X/threads/Y",
|
|
1043
|
+
"message_name": "spaces/X/messages/M.M"}
|
|
1044
|
+
|
|
1045
|
+
For format 3 the helper synthesizes a Chat-API-shaped ``message``
|
|
1046
|
+
dict so downstream code (``_dispatch_message`` →
|
|
1047
|
+
``_build_message_event``) can consume it without branching.
|
|
1048
|
+
"""
|
|
1049
|
+
# Format 1: Workspace Add-ons. The chat block carries one of
|
|
1050
|
+
# messagePayload / membershipPayload / cardClickedPayload depending
|
|
1051
|
+
# on the ce-type. ``_on_pubsub_message`` handles the membership and
|
|
1052
|
+
# card branches before reaching this helper, so here we only accept
|
|
1053
|
+
# message payloads.
|
|
1054
|
+
chat_block = envelope.get("chat") or {}
|
|
1055
|
+
msg_payload_wrapper = chat_block.get("messagePayload") if chat_block else None
|
|
1056
|
+
if msg_payload_wrapper:
|
|
1057
|
+
msg = msg_payload_wrapper.get("message") or {}
|
|
1058
|
+
space = msg_payload_wrapper.get("space") or msg.get("space") or {}
|
|
1059
|
+
return msg, space, "workspace_addons"
|
|
1060
|
+
|
|
1061
|
+
# Format 2: Native Chat API Pub/Sub. Detected by a top-level
|
|
1062
|
+
# ``message`` object plus a ``type`` field; only MESSAGE events
|
|
1063
|
+
# flow through here.
|
|
1064
|
+
if isinstance(envelope.get("message"), dict):
|
|
1065
|
+
if envelope.get("type", "") != "MESSAGE":
|
|
1066
|
+
return None
|
|
1067
|
+
msg = envelope["message"]
|
|
1068
|
+
space = envelope.get("space") or msg.get("space") or {}
|
|
1069
|
+
return msg, space, "native_chat_api"
|
|
1070
|
+
|
|
1071
|
+
# Format 3: Relay / flat. A custom Cloud Run relay typically
|
|
1072
|
+
# forwards Chat events with this shape so the bot can run without
|
|
1073
|
+
# direct GCP credentials.
|
|
1074
|
+
if "event_type" in envelope or "sender_email" in envelope:
|
|
1075
|
+
if envelope.get("event_type", "MESSAGE") != "MESSAGE":
|
|
1076
|
+
return None
|
|
1077
|
+
sender_email = (envelope.get("sender_email") or "").strip()
|
|
1078
|
+
sender_display = (
|
|
1079
|
+
envelope.get("sender_display_name")
|
|
1080
|
+
or sender_email
|
|
1081
|
+
or "Unknown"
|
|
1082
|
+
)
|
|
1083
|
+
# The Chat resource name is unknown for relay events; synthesize
|
|
1084
|
+
# a stable surrogate from the sender email so dedup keys and
|
|
1085
|
+
# session IDs stay deterministic across redelivery.
|
|
1086
|
+
sender_name_surrogate = (
|
|
1087
|
+
"users/relay-"
|
|
1088
|
+
+ (sender_email or "unknown").replace("@", "_at_").replace(".", "_")
|
|
1089
|
+
)
|
|
1090
|
+
text = envelope.get("text", "") or ""
|
|
1091
|
+
# Honor the relay's declared sender_type when present so the
|
|
1092
|
+
# downstream BOT self-filter (sender_type == "BOT") fires for
|
|
1093
|
+
# bot-originated messages forwarded by the relay. Hardcoding
|
|
1094
|
+
# "HUMAN" here meant the bot would re-process its own replies
|
|
1095
|
+
# if the relay forwarded them, and allowed a relay envelope to
|
|
1096
|
+
# impersonate any allowlisted user without ever being marked
|
|
1097
|
+
# as a bot. Default to "HUMAN" for backward compatibility when
|
|
1098
|
+
# the relay does not provide the field.
|
|
1099
|
+
#
|
|
1100
|
+
# Operator contract: the relay MUST forward sender.type from
|
|
1101
|
+
# the upstream Chat event as ``sender_type``. Relays that
|
|
1102
|
+
# forward bot replies as HUMAN (or omit the field) cannot be
|
|
1103
|
+
# distinguished from genuine humans here.
|
|
1104
|
+
sender_type_raw = (envelope.get("sender_type") or "HUMAN")
|
|
1105
|
+
sender_type = str(sender_type_raw).strip().upper() or "HUMAN"
|
|
1106
|
+
if sender_type not in {"HUMAN", "BOT"}:
|
|
1107
|
+
sender_type = "HUMAN"
|
|
1108
|
+
msg: Dict[str, Any] = {
|
|
1109
|
+
"name": envelope.get("message_name", "") or "",
|
|
1110
|
+
"sender": {
|
|
1111
|
+
"name": sender_name_surrogate,
|
|
1112
|
+
"email": sender_email,
|
|
1113
|
+
"displayName": sender_display,
|
|
1114
|
+
"type": sender_type,
|
|
1115
|
+
},
|
|
1116
|
+
"text": text,
|
|
1117
|
+
"argumentText": text,
|
|
1118
|
+
}
|
|
1119
|
+
thread_name = envelope.get("thread_name") or ""
|
|
1120
|
+
if thread_name:
|
|
1121
|
+
msg["thread"] = {"name": thread_name}
|
|
1122
|
+
space = {
|
|
1123
|
+
"name": envelope.get("space_name", "") or "",
|
|
1124
|
+
"spaceType": envelope.get("space_type", "SPACE"),
|
|
1125
|
+
}
|
|
1126
|
+
return msg, space, "relay_flat"
|
|
1127
|
+
|
|
1128
|
+
return None
|
|
1129
|
+
|
|
1130
|
+
def _on_pubsub_message(self, message: Any) -> None:
|
|
1131
|
+
"""Pub/Sub callback — parse envelope and dispatch to asyncio loop.
|
|
1132
|
+
|
|
1133
|
+
Runs in a Pub/Sub SubscriberClient worker thread, NOT the event loop.
|
|
1134
|
+
Never block this function; never raise out of it (that triggers
|
|
1135
|
+
Pub/Sub nack + infinite redelivery).
|
|
1136
|
+
|
|
1137
|
+
Google Chat Events API uses CloudEvents-style Pub/Sub messages. The
|
|
1138
|
+
event type is carried in Pub/Sub message attributes (``ce-type``),
|
|
1139
|
+
not in the JSON body. The body is wrapped in a ``chat`` object whose
|
|
1140
|
+
keys depend on the event type:
|
|
1141
|
+
|
|
1142
|
+
- google.workspace.chat.message.v1.created
|
|
1143
|
+
-> envelope["chat"]["messagePayload"] = {space, message}
|
|
1144
|
+
- google.workspace.chat.membership.v1.created
|
|
1145
|
+
-> envelope["chat"]["membershipPayload"] = {space, membership}
|
|
1146
|
+
- google.workspace.chat.membership.v1.deleted
|
|
1147
|
+
-> envelope["chat"]["membershipPayload"] = {space, membership}
|
|
1148
|
+
"""
|
|
1149
|
+
if self._shutting_down:
|
|
1150
|
+
message.nack()
|
|
1151
|
+
return
|
|
1152
|
+
try:
|
|
1153
|
+
envelope = json.loads(message.data.decode("utf-8"))
|
|
1154
|
+
except Exception:
|
|
1155
|
+
logger.exception("[GoogleChat] Could not parse Pub/Sub envelope")
|
|
1156
|
+
message.ack()
|
|
1157
|
+
return
|
|
1158
|
+
|
|
1159
|
+
attrs = dict(getattr(message, "attributes", {}) or {})
|
|
1160
|
+
ce_type = attrs.get("ce-type") or ""
|
|
1161
|
+
logger.debug(
|
|
1162
|
+
"[GoogleChat] Envelope keys=%s, ce-type=%s",
|
|
1163
|
+
list(envelope.keys()),
|
|
1164
|
+
ce_type,
|
|
1165
|
+
)
|
|
1166
|
+
if os.getenv("GOOGLE_CHAT_DEBUG_RAW"):
|
|
1167
|
+
# Dangerous flag: contains message text and sender email. Route
|
|
1168
|
+
# through the global redaction filter and gate at DEBUG level so
|
|
1169
|
+
# default log configurations never surface it. Operators must
|
|
1170
|
+
# enable DEBUG logging AND set this env var to see the dump.
|
|
1171
|
+
try:
|
|
1172
|
+
from agent.redact import redact_sensitive_text
|
|
1173
|
+
|
|
1174
|
+
dump = redact_sensitive_text(json.dumps(envelope))
|
|
1175
|
+
except Exception:
|
|
1176
|
+
dump = "<redact filter unavailable>"
|
|
1177
|
+
logger.debug("[GoogleChat] RAW envelope (redacted): %s", dump[:2000])
|
|
1178
|
+
|
|
1179
|
+
try:
|
|
1180
|
+
chat_block = envelope.get("chat") or {}
|
|
1181
|
+
|
|
1182
|
+
# --- Membership events ---
|
|
1183
|
+
if "membership" in ce_type or "MEMBERSHIP" in ce_type:
|
|
1184
|
+
mpl = chat_block.get("membershipPayload") or {}
|
|
1185
|
+
space = mpl.get("space") or {}
|
|
1186
|
+
membership = mpl.get("membership") or {}
|
|
1187
|
+
if "created" in ce_type:
|
|
1188
|
+
# ADDED_TO_SPACE for this bot — resolve self user_id.
|
|
1189
|
+
member = membership.get("member") or {}
|
|
1190
|
+
if member.get("type") == "BOT" and not self._bot_user_id:
|
|
1191
|
+
name = member.get("name")
|
|
1192
|
+
if name:
|
|
1193
|
+
self._bot_user_id = name
|
|
1194
|
+
self._save_cached_bot_id(name)
|
|
1195
|
+
logger.info(
|
|
1196
|
+
"[GoogleChat] ADDED_TO_SPACE %s", space.get("name", "?")
|
|
1197
|
+
)
|
|
1198
|
+
else:
|
|
1199
|
+
logger.info(
|
|
1200
|
+
"[GoogleChat] REMOVED_FROM_SPACE %s", space.get("name", "?")
|
|
1201
|
+
)
|
|
1202
|
+
message.ack()
|
|
1203
|
+
return
|
|
1204
|
+
|
|
1205
|
+
# --- Card-click events (v2 follow-up) ---
|
|
1206
|
+
if "widget" in ce_type or "card" in ce_type.lower():
|
|
1207
|
+
logger.info(
|
|
1208
|
+
"[GoogleChat] Card/widget event ack'd (v2 feature, deferred)"
|
|
1209
|
+
)
|
|
1210
|
+
message.ack()
|
|
1211
|
+
return
|
|
1212
|
+
|
|
1213
|
+
# --- Message events ---
|
|
1214
|
+
extracted = self._extract_message_payload(envelope, ce_type)
|
|
1215
|
+
if extracted is None:
|
|
1216
|
+
logger.debug(
|
|
1217
|
+
"[GoogleChat] Envelope did not match a known message format; "
|
|
1218
|
+
"ce-type=%s, keys=%s", ce_type, list(envelope.keys())
|
|
1219
|
+
)
|
|
1220
|
+
message.ack()
|
|
1221
|
+
return
|
|
1222
|
+
|
|
1223
|
+
msg, space, _fmt = extracted
|
|
1224
|
+
sender = msg.get("sender") or {}
|
|
1225
|
+
sender_type = sender.get("type") or ""
|
|
1226
|
+
|
|
1227
|
+
# Self-filter: drop bot-sourced messages (own replies and other bots).
|
|
1228
|
+
if sender_type == "BOT":
|
|
1229
|
+
message.ack()
|
|
1230
|
+
return
|
|
1231
|
+
|
|
1232
|
+
# Dedup guard — Pub/Sub is at-least-once.
|
|
1233
|
+
msg_name = msg.get("name") or ""
|
|
1234
|
+
if msg_name and self._dedup.is_duplicate(msg_name):
|
|
1235
|
+
logger.debug("[GoogleChat] Dedup drop for %s", msg_name)
|
|
1236
|
+
message.ack()
|
|
1237
|
+
return
|
|
1238
|
+
|
|
1239
|
+
# Wrap msg with parent-level space so _build_message_event can find it.
|
|
1240
|
+
msg_with_space = dict(msg)
|
|
1241
|
+
if "space" not in msg_with_space and space:
|
|
1242
|
+
msg_with_space["space"] = space
|
|
1243
|
+
|
|
1244
|
+
# Enrich envelope with a synthetic top-level "space" field so the
|
|
1245
|
+
# dispatch side has a consistent shape regardless of format.
|
|
1246
|
+
enriched_env = dict(envelope)
|
|
1247
|
+
if "space" not in enriched_env and space:
|
|
1248
|
+
enriched_env["space"] = space
|
|
1249
|
+
|
|
1250
|
+
self._submit_on_loop(self._dispatch_message(msg_with_space, enriched_env))
|
|
1251
|
+
message.ack()
|
|
1252
|
+
except Exception:
|
|
1253
|
+
logger.exception("[GoogleChat] Error in _on_pubsub_message")
|
|
1254
|
+
try:
|
|
1255
|
+
message.ack()
|
|
1256
|
+
except Exception:
|
|
1257
|
+
pass
|
|
1258
|
+
|
|
1259
|
+
async def _dispatch_message(self, msg: Dict[str, Any], envelope: Dict[str, Any]) -> None:
|
|
1260
|
+
"""Translate a Chat message payload to a MessageEvent and hand off.
|
|
1261
|
+
|
|
1262
|
+
Intercepts the ``/setup-files`` admin command BEFORE the agent
|
|
1263
|
+
sees it — that's a bot-local OAuth setup flow, not a prompt.
|
|
1264
|
+
Everything else flows to ``handle_message`` as normal.
|
|
1265
|
+
"""
|
|
1266
|
+
try:
|
|
1267
|
+
event = await self._build_message_event(msg, envelope)
|
|
1268
|
+
if event is None:
|
|
1269
|
+
return
|
|
1270
|
+
|
|
1271
|
+
# Short-circuit /setup-files before the agent dispatch.
|
|
1272
|
+
text = (event.text or "").strip()
|
|
1273
|
+
if text.startswith("/setup-files") and event.source is not None:
|
|
1274
|
+
# The sender's email (user_id_alt) is the per-user OAuth
|
|
1275
|
+
# key — the bot stores this user's token at
|
|
1276
|
+
# ${HERMES_HOME}/google_chat_user_tokens/<sanitized>.json
|
|
1277
|
+
# so when User B asks for a file later in B's DM, B's
|
|
1278
|
+
# token gets used (not the first person who set up files).
|
|
1279
|
+
sender_email = (
|
|
1280
|
+
event.source.user_id_alt
|
|
1281
|
+
if event.source and event.source.user_id_alt
|
|
1282
|
+
else None
|
|
1283
|
+
)
|
|
1284
|
+
handled = await self._handle_setup_files_command(
|
|
1285
|
+
chat_id=event.source.chat_id,
|
|
1286
|
+
thread_id=event.source.thread_id,
|
|
1287
|
+
raw_text=text,
|
|
1288
|
+
sender_email=sender_email,
|
|
1289
|
+
)
|
|
1290
|
+
if handled:
|
|
1291
|
+
return
|
|
1292
|
+
|
|
1293
|
+
await self.handle_message(event)
|
|
1294
|
+
except Exception:
|
|
1295
|
+
logger.exception("[GoogleChat] _dispatch_message failed")
|
|
1296
|
+
|
|
1297
|
+
async def _handle_setup_files_command(
|
|
1298
|
+
self,
|
|
1299
|
+
chat_id: str,
|
|
1300
|
+
thread_id: Optional[str],
|
|
1301
|
+
raw_text: str,
|
|
1302
|
+
sender_email: Optional[str] = None,
|
|
1303
|
+
) -> bool:
|
|
1304
|
+
"""Run the in-chat OAuth setup flow for native attachment delivery.
|
|
1305
|
+
|
|
1306
|
+
Returns ``True`` if the message was consumed (no agent dispatch),
|
|
1307
|
+
``False`` if it should fall through.
|
|
1308
|
+
|
|
1309
|
+
Multi-user mode: ``sender_email`` is the asker's identity, which
|
|
1310
|
+
is also the per-user OAuth key. ``status`` / ``start`` / ``revoke``
|
|
1311
|
+
/ code-exchange all operate on THIS user's token slot. When
|
|
1312
|
+
``sender_email`` is ``None`` (e.g. tests, or older inbound events
|
|
1313
|
+
without a populated email field) the handler falls back to the
|
|
1314
|
+
legacy single-user path so pre-multi-user installs keep working.
|
|
1315
|
+
|
|
1316
|
+
Subcommands:
|
|
1317
|
+
/setup-files → show status + next step
|
|
1318
|
+
/setup-files start → print OAuth URL
|
|
1319
|
+
/setup-files revoke → revoke and delete stored token
|
|
1320
|
+
/setup-files <CODE_OR_URL> → exchange auth code for token
|
|
1321
|
+
|
|
1322
|
+
Pre-requisite: client_secret.json must already be on the host
|
|
1323
|
+
(one-time terminal step). The status reply tells the user how to
|
|
1324
|
+
do that if it's missing.
|
|
1325
|
+
"""
|
|
1326
|
+
from . import oauth as oauth_helper
|
|
1327
|
+
|
|
1328
|
+
# Normalize the email: lowercase + strip. The on-disk token path
|
|
1329
|
+
# is sanitized further inside the helper, but having the same
|
|
1330
|
+
# normalization at both ends keeps cache lookups consistent.
|
|
1331
|
+
sender_key = sender_email.strip().lower() if sender_email else None
|
|
1332
|
+
|
|
1333
|
+
parts = raw_text.split(maxsplit=1)
|
|
1334
|
+
# parts[0] is "/setup-files"; parts[1..] is the optional argument
|
|
1335
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
1336
|
+
|
|
1337
|
+
async def _reply(text: str) -> None:
|
|
1338
|
+
body: Dict[str, Any] = {"text": text}
|
|
1339
|
+
if thread_id:
|
|
1340
|
+
body["thread"] = {"name": thread_id}
|
|
1341
|
+
try:
|
|
1342
|
+
await self._create_message(chat_id, body)
|
|
1343
|
+
except Exception:
|
|
1344
|
+
logger.debug(
|
|
1345
|
+
"[GoogleChat] /setup-files reply send failed",
|
|
1346
|
+
exc_info=True,
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
# Status / no-arg: show what's set up and what to do next.
|
|
1350
|
+
if not arg:
|
|
1351
|
+
client_secret_present = (
|
|
1352
|
+
oauth_helper._client_secret_path().exists()
|
|
1353
|
+
)
|
|
1354
|
+
token_path = oauth_helper._token_path(sender_key)
|
|
1355
|
+
token_present = token_path.exists()
|
|
1356
|
+
creds = (
|
|
1357
|
+
oauth_helper.load_user_credentials(sender_key)
|
|
1358
|
+
if token_present else None
|
|
1359
|
+
)
|
|
1360
|
+
if creds is not None:
|
|
1361
|
+
who = sender_key or "shared (legacy)"
|
|
1362
|
+
await _reply(
|
|
1363
|
+
"✅ Native attachment delivery is **active** for "
|
|
1364
|
+
f"`{who}`.\n"
|
|
1365
|
+
f"Token: `{token_path}`\n"
|
|
1366
|
+
"Send `/setup-files revoke` to disable."
|
|
1367
|
+
)
|
|
1368
|
+
return True
|
|
1369
|
+
if not client_secret_present:
|
|
1370
|
+
await _reply(
|
|
1371
|
+
"🔧 Native attachment delivery is **not configured**.\n"
|
|
1372
|
+
"**Step 1 (one-time, on the host):** create OAuth client "
|
|
1373
|
+
"credentials at "
|
|
1374
|
+
"https://console.cloud.google.com/apis/credentials → "
|
|
1375
|
+
"*Create credentials* → *OAuth client ID* → *Desktop app*. "
|
|
1376
|
+
"Download the JSON. Then on the host run:\n"
|
|
1377
|
+
"```\n"
|
|
1378
|
+
"python -m plugins.platforms.google_chat.oauth "
|
|
1379
|
+
"--client-secret /path/to/client_secret.json\n"
|
|
1380
|
+
"```\n"
|
|
1381
|
+
"**Step 2:** come back here and send `/setup-files start`."
|
|
1382
|
+
)
|
|
1383
|
+
return True
|
|
1384
|
+
await _reply(
|
|
1385
|
+
"🔧 Client credentials are stored but you haven't "
|
|
1386
|
+
"authorized yet. Send `/setup-files start` to begin."
|
|
1387
|
+
)
|
|
1388
|
+
return True
|
|
1389
|
+
|
|
1390
|
+
if arg == "start":
|
|
1391
|
+
if not oauth_helper._client_secret_path().exists():
|
|
1392
|
+
await _reply(
|
|
1393
|
+
"⚠️ No client credentials stored on the host. Send "
|
|
1394
|
+
"`/setup-files` (no args) for setup instructions."
|
|
1395
|
+
)
|
|
1396
|
+
return True
|
|
1397
|
+
try:
|
|
1398
|
+
# Reuse the helper logic but capture stdout via a sync
|
|
1399
|
+
# thread so we don't print to the gateway terminal.
|
|
1400
|
+
import io
|
|
1401
|
+
import contextlib
|
|
1402
|
+
buf = io.StringIO()
|
|
1403
|
+
with contextlib.redirect_stdout(buf):
|
|
1404
|
+
await asyncio.to_thread(
|
|
1405
|
+
oauth_helper.get_auth_url, sender_key,
|
|
1406
|
+
)
|
|
1407
|
+
auth_url = buf.getvalue().strip().splitlines()[-1]
|
|
1408
|
+
except SystemExit:
|
|
1409
|
+
await _reply(
|
|
1410
|
+
"❌ Couldn't generate the OAuth URL. Check the gateway "
|
|
1411
|
+
"logs and verify the client_secret.json is valid."
|
|
1412
|
+
)
|
|
1413
|
+
return True
|
|
1414
|
+
except Exception as exc:
|
|
1415
|
+
logger.warning(
|
|
1416
|
+
"[GoogleChat] /setup-files start failed: %s", exc,
|
|
1417
|
+
)
|
|
1418
|
+
await _reply(f"❌ Error: {exc}")
|
|
1419
|
+
return True
|
|
1420
|
+
await _reply(
|
|
1421
|
+
"1. Open this URL in your browser and authorize:\n"
|
|
1422
|
+
f"{auth_url}\n\n"
|
|
1423
|
+
"2. After clicking *Allow*, your browser will fail to load "
|
|
1424
|
+
"`http://localhost:1/?...&code=...`. That's expected.\n\n"
|
|
1425
|
+
"3. Copy the entire failed URL from the browser's URL bar "
|
|
1426
|
+
"and paste it back here as: `/setup-files <PASTE_URL>` "
|
|
1427
|
+
"(or just the `code=...` value).\n\n"
|
|
1428
|
+
"Tip: the URL contains your access grant — keep it private."
|
|
1429
|
+
)
|
|
1430
|
+
return True
|
|
1431
|
+
|
|
1432
|
+
if arg == "revoke":
|
|
1433
|
+
try:
|
|
1434
|
+
import io
|
|
1435
|
+
import contextlib
|
|
1436
|
+
buf = io.StringIO()
|
|
1437
|
+
with contextlib.redirect_stdout(buf):
|
|
1438
|
+
await asyncio.to_thread(oauth_helper.revoke, sender_key)
|
|
1439
|
+
output = buf.getvalue().strip() or "Revoked."
|
|
1440
|
+
except SystemExit:
|
|
1441
|
+
output = "Revoke completed (some steps may have been skipped)."
|
|
1442
|
+
except Exception as exc:
|
|
1443
|
+
logger.warning(
|
|
1444
|
+
"[GoogleChat] /setup-files revoke failed: %s", exc,
|
|
1445
|
+
)
|
|
1446
|
+
await _reply(f"❌ Error revoking: {exc}")
|
|
1447
|
+
return True
|
|
1448
|
+
# Wipe in-memory creds so subsequent uploads fall through to
|
|
1449
|
+
# the setup-instructions text notice immediately. Scope the
|
|
1450
|
+
# eviction to the sender's slot — Bob revoking shouldn't
|
|
1451
|
+
# break Alice's per-user token nor wipe the shared legacy
|
|
1452
|
+
# fallback that other users may still depend on.
|
|
1453
|
+
if sender_key:
|
|
1454
|
+
self._user_creds_by_email.pop(sender_key, None)
|
|
1455
|
+
self._user_chat_api_by_email.pop(sender_key, None)
|
|
1456
|
+
else:
|
|
1457
|
+
self._user_credentials = None
|
|
1458
|
+
self._user_chat_api = None
|
|
1459
|
+
await _reply(f"✅ Done.\n```\n{output}\n```")
|
|
1460
|
+
return True
|
|
1461
|
+
|
|
1462
|
+
# Anything else is treated as the auth code or the failed-redirect
|
|
1463
|
+
# URL the user pasted.
|
|
1464
|
+
try:
|
|
1465
|
+
import io
|
|
1466
|
+
import contextlib
|
|
1467
|
+
buf = io.StringIO()
|
|
1468
|
+
with contextlib.redirect_stdout(buf):
|
|
1469
|
+
await asyncio.to_thread(
|
|
1470
|
+
oauth_helper.exchange_auth_code, arg, sender_key,
|
|
1471
|
+
)
|
|
1472
|
+
output = buf.getvalue().strip()
|
|
1473
|
+
except SystemExit:
|
|
1474
|
+
await _reply(
|
|
1475
|
+
"❌ Token exchange failed. The code may have expired or "
|
|
1476
|
+
"the URL is malformed. Send `/setup-files start` to get "
|
|
1477
|
+
"a fresh OAuth URL."
|
|
1478
|
+
)
|
|
1479
|
+
return True
|
|
1480
|
+
except Exception as exc:
|
|
1481
|
+
logger.warning(
|
|
1482
|
+
"[GoogleChat] /setup-files exchange failed: %s", exc,
|
|
1483
|
+
)
|
|
1484
|
+
await _reply(f"❌ Error: {exc}")
|
|
1485
|
+
return True
|
|
1486
|
+
|
|
1487
|
+
# Re-load credentials into the adapter so the next file send uses
|
|
1488
|
+
# them WITHOUT a gateway restart.
|
|
1489
|
+
try:
|
|
1490
|
+
new_creds = await asyncio.to_thread(
|
|
1491
|
+
oauth_helper.load_user_credentials, sender_key,
|
|
1492
|
+
)
|
|
1493
|
+
if new_creds is not None:
|
|
1494
|
+
new_api = await asyncio.to_thread(
|
|
1495
|
+
lambda: oauth_helper.build_user_chat_service(new_creds)
|
|
1496
|
+
)
|
|
1497
|
+
if sender_key:
|
|
1498
|
+
self._user_creds_by_email[sender_key] = new_creds
|
|
1499
|
+
self._user_chat_api_by_email[sender_key] = new_api
|
|
1500
|
+
else:
|
|
1501
|
+
self._user_credentials = new_creds
|
|
1502
|
+
self._user_chat_api = new_api
|
|
1503
|
+
await _reply(
|
|
1504
|
+
"✅ Authorized! Native attachment delivery is now "
|
|
1505
|
+
"active. Try asking me to send you a PDF."
|
|
1506
|
+
)
|
|
1507
|
+
return True
|
|
1508
|
+
except Exception as exc:
|
|
1509
|
+
logger.warning(
|
|
1510
|
+
"[GoogleChat] post-exchange creds load failed: %s", exc,
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
await _reply(
|
|
1514
|
+
"⚠️ Token exchanged but the gateway couldn't load the new "
|
|
1515
|
+
"credentials in-memory. Restart the gateway and the token "
|
|
1516
|
+
f"at `{oauth_helper._token_path(sender_key)}` will be picked "
|
|
1517
|
+
f"up.\nHelper output:\n```\n{output}\n```"
|
|
1518
|
+
)
|
|
1519
|
+
return True
|
|
1520
|
+
|
|
1521
|
+
async def _build_message_event(
|
|
1522
|
+
self, msg: Dict[str, Any], envelope: Dict[str, Any]
|
|
1523
|
+
) -> Optional[MessageEvent]:
|
|
1524
|
+
"""Parse a Chat API message into a hermes MessageEvent."""
|
|
1525
|
+
space = envelope.get("space") or msg.get("space") or {}
|
|
1526
|
+
space_name = space.get("name") or "" # "spaces/XXX"
|
|
1527
|
+
space_type = (space.get("type") or space.get("spaceType") or "").upper()
|
|
1528
|
+
thread = msg.get("thread") or {}
|
|
1529
|
+
thread_name = thread.get("name") or None
|
|
1530
|
+
sender = msg.get("sender") or {}
|
|
1531
|
+
sender_name = sender.get("name") or ""
|
|
1532
|
+
sender_display = sender.get("displayName") or sender.get("email") or sender_name
|
|
1533
|
+
sender_email = sender.get("email") or ""
|
|
1534
|
+
|
|
1535
|
+
# Cache the asker's email per chat_id so _send_file can pick the
|
|
1536
|
+
# right per-user OAuth token when the agent later wants to send
|
|
1537
|
+
# an attachment in this conversation. Lower-cased so cache hits
|
|
1538
|
+
# match the sanitized token-file lookup.
|
|
1539
|
+
if sender_email and space_name:
|
|
1540
|
+
self._last_sender_by_chat[space_name] = sender_email.strip().lower()
|
|
1541
|
+
|
|
1542
|
+
chat_type = "dm" if space_type in ("DIRECT_MESSAGE", "DM") else "group"
|
|
1543
|
+
text = msg.get("argumentText") or msg.get("text") or ""
|
|
1544
|
+
text = text.strip()
|
|
1545
|
+
|
|
1546
|
+
# Slash command: emit MessageType.COMMAND with normalized text.
|
|
1547
|
+
slash = msg.get("slashCommand") or {}
|
|
1548
|
+
is_slash = bool(slash)
|
|
1549
|
+
if is_slash:
|
|
1550
|
+
command_id = str(slash.get("commandId") or "")
|
|
1551
|
+
if command_id and not text.startswith("/"):
|
|
1552
|
+
text = f"/cmd_{command_id} {text}".strip()
|
|
1553
|
+
|
|
1554
|
+
# Attachments: download and cache.
|
|
1555
|
+
media_urls: List[str] = []
|
|
1556
|
+
media_types: List[str] = []
|
|
1557
|
+
message_type = MessageType.TEXT
|
|
1558
|
+
attachments = msg.get("attachment") or []
|
|
1559
|
+
for att in attachments:
|
|
1560
|
+
try:
|
|
1561
|
+
local_path, mime = await self._download_attachment(att)
|
|
1562
|
+
except Exception:
|
|
1563
|
+
logger.exception("[GoogleChat] attachment download failed")
|
|
1564
|
+
continue
|
|
1565
|
+
if not local_path:
|
|
1566
|
+
continue
|
|
1567
|
+
media_urls.append(local_path)
|
|
1568
|
+
media_types.append(mime or "application/octet-stream")
|
|
1569
|
+
# Prefer the first-seen type for MessageType if no text present.
|
|
1570
|
+
if message_type == MessageType.TEXT and not text:
|
|
1571
|
+
message_type = _mime_for_message_type(mime or "")
|
|
1572
|
+
|
|
1573
|
+
if is_slash:
|
|
1574
|
+
message_type = MessageType.COMMAND
|
|
1575
|
+
|
|
1576
|
+
# Increment the persistent inbound count for this thread.
|
|
1577
|
+
# The PRE-increment value (==0 for the very first time we see
|
|
1578
|
+
# this thread, persisted across gateway restarts) drives the
|
|
1579
|
+
# main-flow-vs-side-thread heuristic below.
|
|
1580
|
+
prev_thread_count = 0
|
|
1581
|
+
if thread_name and space_name:
|
|
1582
|
+
prev_thread_count = self._thread_count_store.incr(
|
|
1583
|
+
space_name, thread_name
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
# Session-thread + outbound-thread routing for DMs:
|
|
1587
|
+
# - prev_count == 0 → first message in this thread. Google Chat
|
|
1588
|
+
# creates a fresh thread per top-level message in the DM input
|
|
1589
|
+
# box; treat as "main flow" so all top-level messages share
|
|
1590
|
+
# one DM session and the user keeps continuity. The bot's
|
|
1591
|
+
# reply ALSO must NOT thread with the user message — if we
|
|
1592
|
+
# pass thread.name on outbound, Chat displays the pair as an
|
|
1593
|
+
# expandable thread under the user's message instead of two
|
|
1594
|
+
# adjacent top-level cards.
|
|
1595
|
+
# - prev_count >= 1 → user explicitly engaged a thread that
|
|
1596
|
+
# already had messages (clicked "Reply in thread" on a prior
|
|
1597
|
+
# message). Isolate session by chat_id+thread_id, AND keep
|
|
1598
|
+
# the bot's reply inside that thread.
|
|
1599
|
+
#
|
|
1600
|
+
# For groups, threads ARE meaningful conversational containers
|
|
1601
|
+
# (Telegram forum / Discord thread parity); always isolate AND
|
|
1602
|
+
# always reply in-thread.
|
|
1603
|
+
if chat_type == "dm":
|
|
1604
|
+
is_side_thread = prev_thread_count > 0
|
|
1605
|
+
session_thread_id = thread_name if is_side_thread else None
|
|
1606
|
+
# Outbound thread cache: populated only when side-thread, so
|
|
1607
|
+
# _resolve_thread_id falls through to "no thread" on main
|
|
1608
|
+
# flow and the bot reply lands as a top-level sibling.
|
|
1609
|
+
if thread_name and space_name and is_side_thread:
|
|
1610
|
+
self._last_inbound_thread[space_name] = thread_name
|
|
1611
|
+
elif space_name:
|
|
1612
|
+
self._last_inbound_thread.pop(space_name, None)
|
|
1613
|
+
else:
|
|
1614
|
+
session_thread_id = thread_name
|
|
1615
|
+
# Groups always reply in-thread.
|
|
1616
|
+
if thread_name and space_name:
|
|
1617
|
+
self._last_inbound_thread[space_name] = thread_name
|
|
1618
|
+
|
|
1619
|
+
source = self.build_source(
|
|
1620
|
+
chat_id=space_name,
|
|
1621
|
+
chat_name=space.get("displayName") or space.get("name") or "",
|
|
1622
|
+
chat_type=chat_type,
|
|
1623
|
+
# ``user_id`` is the canonical identity used by allowlists,
|
|
1624
|
+
# session keys, and audit. Operators configure
|
|
1625
|
+
# ``GOOGLE_CHAT_ALLOWED_USERS`` with email addresses (the
|
|
1626
|
+
# value Google Chat surfaces in its UI), so the email is
|
|
1627
|
+
# the natural canonical id. The Chat resource name
|
|
1628
|
+
# ``users/{id}`` moves to ``user_id_alt`` for traceability
|
|
1629
|
+
# and Chat-API operations that need it. Falls back to the
|
|
1630
|
+
# resource name when sender has no email (rare — bot-to-bot
|
|
1631
|
+
# or system events). Pattern lifted from PR #14965.
|
|
1632
|
+
user_id=(sender_email or sender_name),
|
|
1633
|
+
user_name=sender_display,
|
|
1634
|
+
thread_id=session_thread_id,
|
|
1635
|
+
user_id_alt=(sender_name or None),
|
|
1636
|
+
)
|
|
1637
|
+
return MessageEvent(
|
|
1638
|
+
text=text,
|
|
1639
|
+
message_type=message_type,
|
|
1640
|
+
source=source,
|
|
1641
|
+
raw_message=msg,
|
|
1642
|
+
message_id=msg.get("name") or None,
|
|
1643
|
+
media_urls=media_urls,
|
|
1644
|
+
media_types=media_types,
|
|
1645
|
+
)
|
|
1646
|
+
|
|
1647
|
+
async def _download_attachment(
|
|
1648
|
+
self, attachment: Dict[str, Any]
|
|
1649
|
+
) -> Tuple[Optional[str], Optional[str]]:
|
|
1650
|
+
"""Download an inbound attachment to the local cache; return (path, mime).
|
|
1651
|
+
|
|
1652
|
+
Priority for bot Service Accounts:
|
|
1653
|
+
|
|
1654
|
+
1. ``attachmentDataRef.resourceName`` via ``chat.media.download`` —
|
|
1655
|
+
the supported bot path. The Service Account bearer token has
|
|
1656
|
+
``chat.bot`` scope which the Chat API authorises against the
|
|
1657
|
+
space membership.
|
|
1658
|
+
2. Drive-hosted files (``source == 'DRIVE_FILE'``) require user
|
|
1659
|
+
OAuth and Drive scope; skip with a log.
|
|
1660
|
+
3. Direct HTTP fetch of ``downloadUri`` only as a last resort —
|
|
1661
|
+
that URL is meant for user OAuth tokens (chat.google.com
|
|
1662
|
+
returns 401 for SA bearer tokens) and is unlikely to work,
|
|
1663
|
+
but we keep the path for forward-compat with Google changes.
|
|
1664
|
+
"""
|
|
1665
|
+
mime = attachment.get("contentType") or ""
|
|
1666
|
+
source = attachment.get("source") or ""
|
|
1667
|
+
name = attachment.get("name") or ""
|
|
1668
|
+
attachment_data_ref = attachment.get("attachmentDataRef") or {}
|
|
1669
|
+
resource_name = attachment_data_ref.get("resourceName") or ""
|
|
1670
|
+
download_uri = attachment.get("downloadUri") or ""
|
|
1671
|
+
|
|
1672
|
+
# NOTE on ``source == "DRIVE_FILE"``: Google Chat tags BOTH
|
|
1673
|
+
# drag-and-drop chat uploads AND Drive-picker shares with this
|
|
1674
|
+
# source string, but the two have different access models.
|
|
1675
|
+
# Drag-and-drop uploads come with an ``attachmentDataRef.resourceName``
|
|
1676
|
+
# that bot SA tokens CAN download via ``media.download_media``.
|
|
1677
|
+
# Pure Drive-picker shares often lack that field and require
|
|
1678
|
+
# user OAuth + Drive scope (which we deliberately don't request).
|
|
1679
|
+
# So we only short-circuit when there's nothing the bot path
|
|
1680
|
+
# can use — otherwise try the bot path first.
|
|
1681
|
+
if source == "DRIVE_FILE" and not resource_name:
|
|
1682
|
+
logger.info(
|
|
1683
|
+
"[GoogleChat] Skipping Drive-picker attachment (no "
|
|
1684
|
+
"resourceName, would need user-OAuth Drive scope)"
|
|
1685
|
+
)
|
|
1686
|
+
return None, mime
|
|
1687
|
+
|
|
1688
|
+
data: Optional[bytes] = None
|
|
1689
|
+
|
|
1690
|
+
# Path 1: media.download with attachmentDataRef.resourceName (bot-path).
|
|
1691
|
+
if resource_name:
|
|
1692
|
+
def _fetch_media() -> bytes:
|
|
1693
|
+
req = self._chat_api.media().download_media(
|
|
1694
|
+
resourceName=resource_name,
|
|
1695
|
+
)
|
|
1696
|
+
from googleapiclient.http import MediaIoBaseDownload
|
|
1697
|
+
import io
|
|
1698
|
+
|
|
1699
|
+
buf = io.BytesIO()
|
|
1700
|
+
downloader = MediaIoBaseDownload(buf, req)
|
|
1701
|
+
done = False
|
|
1702
|
+
while not done:
|
|
1703
|
+
_status, done = downloader.next_chunk()
|
|
1704
|
+
return buf.getvalue()
|
|
1705
|
+
|
|
1706
|
+
try:
|
|
1707
|
+
data = await asyncio.to_thread(_fetch_media)
|
|
1708
|
+
except HttpError as exc:
|
|
1709
|
+
logger.warning(
|
|
1710
|
+
"[GoogleChat] media.download_media failed: %s",
|
|
1711
|
+
_redact_sensitive(str(exc)),
|
|
1712
|
+
)
|
|
1713
|
+
data = None
|
|
1714
|
+
|
|
1715
|
+
# Path 2: downloadUri fallback (rarely works with SA tokens, but try).
|
|
1716
|
+
if data is None and download_uri:
|
|
1717
|
+
if not _is_google_owned_host(download_uri):
|
|
1718
|
+
logger.warning(
|
|
1719
|
+
"[GoogleChat] Rejecting attachment fetch: non-Google host"
|
|
1720
|
+
)
|
|
1721
|
+
return None, mime
|
|
1722
|
+
|
|
1723
|
+
def _fetch_uri() -> bytes:
|
|
1724
|
+
import google.auth.transport.requests as gar
|
|
1725
|
+
|
|
1726
|
+
authed_session = gar.AuthorizedSession(self._credentials)
|
|
1727
|
+
resp = authed_session.get(download_uri, timeout=30)
|
|
1728
|
+
resp.raise_for_status()
|
|
1729
|
+
return resp.content
|
|
1730
|
+
|
|
1731
|
+
try:
|
|
1732
|
+
data = await asyncio.to_thread(_fetch_uri)
|
|
1733
|
+
except Exception as exc:
|
|
1734
|
+
logger.warning(
|
|
1735
|
+
"[GoogleChat] downloadUri fetch failed (SA tokens often "
|
|
1736
|
+
"lack access here; this is expected for user-uploaded "
|
|
1737
|
+
"content): %s",
|
|
1738
|
+
_redact_sensitive(str(exc)),
|
|
1739
|
+
)
|
|
1740
|
+
return None, mime
|
|
1741
|
+
|
|
1742
|
+
if data is None:
|
|
1743
|
+
return None, mime
|
|
1744
|
+
|
|
1745
|
+
# Cache based on MIME. Upstream's cache_* helpers expect `ext` for
|
|
1746
|
+
# media (image/audio/video) and a positional `filename` for docs.
|
|
1747
|
+
filename = name.split("/")[-1] if name else "attachment"
|
|
1748
|
+
if "." in filename:
|
|
1749
|
+
ext = "." + filename.rsplit(".", 1)[-1].lower()
|
|
1750
|
+
else:
|
|
1751
|
+
ext = ""
|
|
1752
|
+
if mime.startswith("image/"):
|
|
1753
|
+
local = cache_image_from_bytes(data, ext=ext or ".jpg")
|
|
1754
|
+
elif mime.startswith("audio/"):
|
|
1755
|
+
local = cache_audio_from_bytes(data, ext=ext or ".ogg")
|
|
1756
|
+
elif mime.startswith("video/"):
|
|
1757
|
+
local = cache_video_from_bytes(data, ext=ext or ".mp4")
|
|
1758
|
+
else:
|
|
1759
|
+
local = cache_document_from_bytes(data, filename)
|
|
1760
|
+
return local, mime
|
|
1761
|
+
|
|
1762
|
+
# ------------------------------------------------------------------
|
|
1763
|
+
# Outbound send paths
|
|
1764
|
+
# ------------------------------------------------------------------
|
|
1765
|
+
async def send(
|
|
1766
|
+
self,
|
|
1767
|
+
chat_id: str,
|
|
1768
|
+
content: str,
|
|
1769
|
+
reply_to: Optional[str] = None,
|
|
1770
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1771
|
+
) -> SendResult:
|
|
1772
|
+
"""Send a text message.
|
|
1773
|
+
|
|
1774
|
+
Signature matches ``BasePlatformAdapter.send``: ``content`` is the
|
|
1775
|
+
message body, ``reply_to`` is an optional message_id (the inbound
|
|
1776
|
+
message to thread under), and ``metadata`` may carry ``thread_id``
|
|
1777
|
+
(the resolved Google Chat ``spaces/X/threads/Y`` resource name).
|
|
1778
|
+
|
|
1779
|
+
If a typing card is tracked for this chat, transform it in-place via
|
|
1780
|
+
``messages.patch`` — NO delete+create. Google Chat shows a tombstone
|
|
1781
|
+
("Message deleted by its author") on delete, which is visual noise.
|
|
1782
|
+
Patch rewrites the text of the existing message seamlessly.
|
|
1783
|
+
|
|
1784
|
+
Also pauses the base class's ``_keep_typing`` loop for this chat so
|
|
1785
|
+
it can't post a racing typing card between the patch and the reply.
|
|
1786
|
+
|
|
1787
|
+
If ``content`` exceeds MAX_MESSAGE_LENGTH, the first chunk patches
|
|
1788
|
+
the typing card (if any), subsequent chunks are new messages.
|
|
1789
|
+
"""
|
|
1790
|
+
thread_id = self._resolve_thread_id(reply_to, metadata, chat_id=chat_id)
|
|
1791
|
+
self.pause_typing_for_chat(chat_id)
|
|
1792
|
+
try:
|
|
1793
|
+
# Convert standard Markdown emitted by the LLM to Chat's dialect
|
|
1794
|
+
# and strip invisible Unicode that renders as tofu (□). Runs
|
|
1795
|
+
# BEFORE chunking so the size limit applies to the rendered
|
|
1796
|
+
# form, not the source markdown.
|
|
1797
|
+
chunks = self._chunk_text(self.format_message(content))
|
|
1798
|
+
if not chunks:
|
|
1799
|
+
return SendResult(success=False, error="empty message")
|
|
1800
|
+
|
|
1801
|
+
last_result: Optional[SendResult] = None
|
|
1802
|
+
typing_msg_name = self._typing_messages.pop(chat_id, None)
|
|
1803
|
+
# Treat any earlier sentinel as "no real card to patch" — defensive.
|
|
1804
|
+
if typing_msg_name == _TYPING_CONSUMED_SENTINEL:
|
|
1805
|
+
typing_msg_name = None
|
|
1806
|
+
patched_typing = False
|
|
1807
|
+
|
|
1808
|
+
for idx, chunk in enumerate(chunks):
|
|
1809
|
+
body: Dict[str, Any] = {"text": chunk}
|
|
1810
|
+
# Only set thread on new-message create path. Patch inherits.
|
|
1811
|
+
if thread_id and (idx > 0 or not typing_msg_name):
|
|
1812
|
+
body["thread"] = {"name": thread_id}
|
|
1813
|
+
try:
|
|
1814
|
+
if idx == 0 and typing_msg_name:
|
|
1815
|
+
result = await self._patch_message(typing_msg_name, body)
|
|
1816
|
+
patched_typing = True
|
|
1817
|
+
else:
|
|
1818
|
+
result = await self._create_message(chat_id, body)
|
|
1819
|
+
last_result = result
|
|
1820
|
+
except HttpError as exc:
|
|
1821
|
+
status = getattr(getattr(exc, "resp", None), "status", None)
|
|
1822
|
+
if status == 403:
|
|
1823
|
+
self._set_fatal_error(
|
|
1824
|
+
code="chat_forbidden",
|
|
1825
|
+
message="Bot lacks access (removed from space or perms revoked)",
|
|
1826
|
+
retryable=False,
|
|
1827
|
+
)
|
|
1828
|
+
return SendResult(success=False, error=str(exc))
|
|
1829
|
+
if status == 404:
|
|
1830
|
+
# Typing card was deleted out from under us, or space
|
|
1831
|
+
# is gone. Fall through to creating a new message on
|
|
1832
|
+
# the first-chunk patch failure.
|
|
1833
|
+
if idx == 0 and typing_msg_name:
|
|
1834
|
+
logger.info(
|
|
1835
|
+
"[GoogleChat] Typing card disappeared; creating new message"
|
|
1836
|
+
)
|
|
1837
|
+
typing_msg_name = None
|
|
1838
|
+
result = await self._create_message(chat_id, body)
|
|
1839
|
+
last_result = result
|
|
1840
|
+
continue
|
|
1841
|
+
logger.info("[GoogleChat] send target 404; skipping")
|
|
1842
|
+
return SendResult(success=False, error="target not found")
|
|
1843
|
+
if status == 429:
|
|
1844
|
+
self._rate_limit_hits[chat_id] = (
|
|
1845
|
+
self._rate_limit_hits.get(chat_id, 0) + 1
|
|
1846
|
+
)
|
|
1847
|
+
if self._rate_limit_hits[chat_id] >= _RATE_LIMIT_WARN_THRESHOLD:
|
|
1848
|
+
logger.warning(
|
|
1849
|
+
"[GoogleChat] Rate limit hit %d times on chat; throttling",
|
|
1850
|
+
self._rate_limit_hits[chat_id],
|
|
1851
|
+
)
|
|
1852
|
+
raise
|
|
1853
|
+
raise
|
|
1854
|
+
if last_result is None:
|
|
1855
|
+
return SendResult(success=False, error="empty message")
|
|
1856
|
+
# Mark the chat's typing slot as "consumed" so the base class's
|
|
1857
|
+
# _keep_typing loop (which may iterate one more time before
|
|
1858
|
+
# typing_task.cancel() lands) does not post a fresh marker that
|
|
1859
|
+
# the safety-net stop_typing would then delete and tombstone.
|
|
1860
|
+
# Cleared in on_processing_complete.
|
|
1861
|
+
if patched_typing:
|
|
1862
|
+
self._typing_messages[chat_id] = _TYPING_CONSUMED_SENTINEL
|
|
1863
|
+
return last_result
|
|
1864
|
+
finally:
|
|
1865
|
+
self.resume_typing_for_chat(chat_id)
|
|
1866
|
+
|
|
1867
|
+
async def edit_message(
|
|
1868
|
+
self,
|
|
1869
|
+
chat_id: str,
|
|
1870
|
+
message_id: str,
|
|
1871
|
+
content: str,
|
|
1872
|
+
*,
|
|
1873
|
+
finalize: bool = False,
|
|
1874
|
+
) -> SendResult:
|
|
1875
|
+
"""Edit a previously sent message via ``messages.patch``.
|
|
1876
|
+
|
|
1877
|
+
Required for the gateway tool-progress + token-streaming pipeline:
|
|
1878
|
+
``GatewayStreamConsumer`` and ``send_progress_messages`` both gate
|
|
1879
|
+
on this method being overridden (see gateway/run.py:10199 and
|
|
1880
|
+
gateway/stream_consumer.py). Without it, Google Chat shows no
|
|
1881
|
+
tool activity (no "🔍 web_search…", no progressive token edits).
|
|
1882
|
+
|
|
1883
|
+
``message_id`` is the Google Chat resource name
|
|
1884
|
+
``spaces/X/messages/Y``. ``finalize`` is unused here — Google
|
|
1885
|
+
Chat's patch API has no streaming lifecycle state, so the same
|
|
1886
|
+
patch closes the stream and any prior edit.
|
|
1887
|
+
|
|
1888
|
+
404 (message gone) and 403 (perms revoked) are reported as
|
|
1889
|
+
non-success; the gateway falls back to ``send()`` for the next
|
|
1890
|
+
edit cycle.
|
|
1891
|
+
"""
|
|
1892
|
+
if not message_id:
|
|
1893
|
+
return SendResult(success=False, error="missing message_id")
|
|
1894
|
+
# Google Chat caps message text at 4096; we use 4000 elsewhere.
|
|
1895
|
+
if len(content) > _MAX_TEXT_LENGTH:
|
|
1896
|
+
content = content[: _MAX_TEXT_LENGTH - 1] + "…"
|
|
1897
|
+
try:
|
|
1898
|
+
return await self._patch_message(message_id, {"text": content})
|
|
1899
|
+
except HttpError as exc:
|
|
1900
|
+
status = getattr(getattr(exc, "resp", None), "status", None)
|
|
1901
|
+
if status == 429:
|
|
1902
|
+
self._rate_limit_hits[chat_id] = (
|
|
1903
|
+
self._rate_limit_hits.get(chat_id, 0) + 1
|
|
1904
|
+
)
|
|
1905
|
+
return SendResult(
|
|
1906
|
+
success=False, error=_redact_sensitive(str(exc))
|
|
1907
|
+
)
|
|
1908
|
+
except Exception as exc:
|
|
1909
|
+
logger.debug("[GoogleChat] edit_message failed", exc_info=True)
|
|
1910
|
+
return SendResult(success=False, error=str(exc))
|
|
1911
|
+
|
|
1912
|
+
async def delete_message(self, chat_id: str, message_id: str) -> bool:
|
|
1913
|
+
"""Delete a message — used sparingly (deletion creates a tombstone).
|
|
1914
|
+
|
|
1915
|
+
The base contract returns False on unsupported. We do support it,
|
|
1916
|
+
but most internal code should prefer ``edit_message`` to avoid the
|
|
1917
|
+
"Message deleted by its author" tombstone. Provided so the
|
|
1918
|
+
gateway's stream-consumer fallback paths (e.g. removing an aborted
|
|
1919
|
+
partial preview) work correctly when explicit deletion is the
|
|
1920
|
+
right call.
|
|
1921
|
+
"""
|
|
1922
|
+
if not message_id:
|
|
1923
|
+
return False
|
|
1924
|
+
|
|
1925
|
+
def _do_delete() -> None:
|
|
1926
|
+
(
|
|
1927
|
+
self._chat_api.spaces()
|
|
1928
|
+
.messages()
|
|
1929
|
+
.delete(name=message_id)
|
|
1930
|
+
.execute(http=self._new_authed_http())
|
|
1931
|
+
)
|
|
1932
|
+
|
|
1933
|
+
try:
|
|
1934
|
+
await asyncio.to_thread(_do_delete)
|
|
1935
|
+
return True
|
|
1936
|
+
except HttpError as exc:
|
|
1937
|
+
status = getattr(getattr(exc, "resp", None), "status", None)
|
|
1938
|
+
if status in (403, 404):
|
|
1939
|
+
return False
|
|
1940
|
+
logger.debug(
|
|
1941
|
+
"[GoogleChat] delete_message failed: %s",
|
|
1942
|
+
_redact_sensitive(str(exc)),
|
|
1943
|
+
)
|
|
1944
|
+
return False
|
|
1945
|
+
except Exception:
|
|
1946
|
+
logger.debug("[GoogleChat] delete_message failed", exc_info=True)
|
|
1947
|
+
return False
|
|
1948
|
+
|
|
1949
|
+
async def _patch_message(
|
|
1950
|
+
self, message_name: str, body: Dict[str, Any]
|
|
1951
|
+
) -> SendResult:
|
|
1952
|
+
"""Update a message's text (and optionally cards) in-place."""
|
|
1953
|
+
update_mask_fields = []
|
|
1954
|
+
if "text" in body:
|
|
1955
|
+
update_mask_fields.append("text")
|
|
1956
|
+
if "cardsV2" in body:
|
|
1957
|
+
update_mask_fields.append("cardsV2")
|
|
1958
|
+
update_mask = ",".join(update_mask_fields) or "text"
|
|
1959
|
+
|
|
1960
|
+
# Patch body cannot carry thread (immutable).
|
|
1961
|
+
patch_body = {k: v for k, v in body.items() if k not in ("thread",)}
|
|
1962
|
+
|
|
1963
|
+
def _do_patch() -> Dict[str, Any]:
|
|
1964
|
+
return (
|
|
1965
|
+
self._chat_api.spaces()
|
|
1966
|
+
.messages()
|
|
1967
|
+
.patch(name=message_name, updateMask=update_mask, body=patch_body)
|
|
1968
|
+
.execute(http=self._new_authed_http())
|
|
1969
|
+
)
|
|
1970
|
+
|
|
1971
|
+
resp = await asyncio.to_thread(_do_patch)
|
|
1972
|
+
return SendResult(success=True, message_id=resp.get("name", message_name))
|
|
1973
|
+
|
|
1974
|
+
def _chunk_text(self, text: str) -> List[str]:
|
|
1975
|
+
if not text:
|
|
1976
|
+
return []
|
|
1977
|
+
if len(text) <= _MAX_TEXT_LENGTH:
|
|
1978
|
+
return [text]
|
|
1979
|
+
chunks: List[str] = []
|
|
1980
|
+
remaining = text
|
|
1981
|
+
while remaining:
|
|
1982
|
+
if len(remaining) <= _MAX_TEXT_LENGTH:
|
|
1983
|
+
chunks.append(remaining)
|
|
1984
|
+
break
|
|
1985
|
+
# Try to split on a newline near the cutoff.
|
|
1986
|
+
cut = remaining.rfind("\n", 0, _MAX_TEXT_LENGTH)
|
|
1987
|
+
if cut < _MAX_TEXT_LENGTH // 2:
|
|
1988
|
+
cut = _MAX_TEXT_LENGTH
|
|
1989
|
+
chunks.append(remaining[:cut])
|
|
1990
|
+
remaining = remaining[cut:].lstrip()
|
|
1991
|
+
return chunks
|
|
1992
|
+
|
|
1993
|
+
# ------------------------------------------------------------------
|
|
1994
|
+
# Outbound formatting
|
|
1995
|
+
# ------------------------------------------------------------------
|
|
1996
|
+
# Invisible Unicode codepoints that render as tofu (□) in Google
|
|
1997
|
+
# Chat's restricted font stack. ZWJ/ZWNJ/ZWS are the glue inside
|
|
1998
|
+
# composite emoji and bidirectional text; Variation Selectors
|
|
1999
|
+
# control text-vs-emoji presentation but Chat ignores them and
|
|
2000
|
+
# often shows a blank box. Pattern lifted from PR #14965.
|
|
2001
|
+
_INVISIBLE_RE = re.compile(
|
|
2002
|
+
"["
|
|
2003
|
+
"" # Zero-Width Space
|
|
2004
|
+
"" # Zero-Width Non-Joiner
|
|
2005
|
+
"" # Zero-Width Joiner (ZWJ)
|
|
2006
|
+
"" # LTR / RTL marks
|
|
2007
|
+
"" # Word Joiner
|
|
2008
|
+
"" # BOM / Zero-Width No-Break Space
|
|
2009
|
+
"︀-️" # Variation Selectors 1-16 (VS1–VS16)
|
|
2010
|
+
"\U000e0100-\U000e01ef" # Variation Selectors 17-256
|
|
2011
|
+
"]"
|
|
2012
|
+
)
|
|
2013
|
+
|
|
2014
|
+
@classmethod
|
|
2015
|
+
def format_message(cls, content: str) -> str:
|
|
2016
|
+
"""Convert standard Markdown to Google Chat's formatting dialect.
|
|
2017
|
+
|
|
2018
|
+
Google Chat renders a small subset: ``*bold*``, ``_italic_``,
|
|
2019
|
+
``~strikethrough~``, fenced/inline code. Standard Markdown
|
|
2020
|
+
constructs (``**bold**``, ``# headers``, ``[text](url)``) do
|
|
2021
|
+
not render and need conversion before they reach Chat.
|
|
2022
|
+
|
|
2023
|
+
Code blocks (fenced AND inline) are protected from transformation
|
|
2024
|
+
via placeholder substitution so backticks-wrapped content with
|
|
2025
|
+
literal asterisks or brackets stays intact. Invisible Unicode
|
|
2026
|
+
codepoints that render as tofu in Chat's restricted font stack
|
|
2027
|
+
are stripped at the end. Empty/None input passes through.
|
|
2028
|
+
|
|
2029
|
+
Pattern lifted from PR #14965.
|
|
2030
|
+
"""
|
|
2031
|
+
if not content:
|
|
2032
|
+
return content
|
|
2033
|
+
|
|
2034
|
+
text = content
|
|
2035
|
+
placeholders: Dict[str, str] = {}
|
|
2036
|
+
counter = [0]
|
|
2037
|
+
|
|
2038
|
+
def _ph(value: str) -> str:
|
|
2039
|
+
key = f"\x00GC{counter[0]}\x00"
|
|
2040
|
+
counter[0] += 1
|
|
2041
|
+
placeholders[key] = value
|
|
2042
|
+
return key
|
|
2043
|
+
|
|
2044
|
+
# Protect fenced and inline code blocks from transformation.
|
|
2045
|
+
# Fenced blocks first (``` ... ```), then inline code (`...`).
|
|
2046
|
+
text = re.sub(
|
|
2047
|
+
r"(```(?:[^\n]*\n)?[\s\S]*?```)",
|
|
2048
|
+
lambda m: _ph(m.group(0)),
|
|
2049
|
+
text,
|
|
2050
|
+
)
|
|
2051
|
+
text = re.sub(r"(`[^`]+`)", lambda m: _ph(m.group(0)), text)
|
|
2052
|
+
|
|
2053
|
+
# Headers (## Title) → *Title* (Chat has no header support).
|
|
2054
|
+
text = re.sub(
|
|
2055
|
+
r"^#{1,6}\s+(.+)$",
|
|
2056
|
+
lambda m: _ph(f"*{m.group(1).strip()}*"),
|
|
2057
|
+
text,
|
|
2058
|
+
flags=re.MULTILINE,
|
|
2059
|
+
)
|
|
2060
|
+
|
|
2061
|
+
# Bold+italic: ***text*** → *_text_*
|
|
2062
|
+
text = re.sub(
|
|
2063
|
+
r"\*\*\*(.+?)\*\*\*",
|
|
2064
|
+
lambda m: _ph(f"*_{m.group(1)}_*"),
|
|
2065
|
+
text,
|
|
2066
|
+
)
|
|
2067
|
+
|
|
2068
|
+
# Bold: **text** → *text* (Chat uses single asterisks).
|
|
2069
|
+
text = re.sub(
|
|
2070
|
+
r"\*\*(.+?)\*\*",
|
|
2071
|
+
lambda m: _ph(f"*{m.group(1)}*"),
|
|
2072
|
+
text,
|
|
2073
|
+
)
|
|
2074
|
+
|
|
2075
|
+
# Markdown links [text](url) → <url|text> (Slack-style angle-bracket).
|
|
2076
|
+
text = re.sub(
|
|
2077
|
+
r"\[([^\]]+)\]\(([^)]+)\)",
|
|
2078
|
+
lambda m: _ph(f"<{m.group(2)}|{m.group(1)}>"),
|
|
2079
|
+
text,
|
|
2080
|
+
)
|
|
2081
|
+
|
|
2082
|
+
# Strip invisible Unicode that renders as tofu.
|
|
2083
|
+
text = cls._INVISIBLE_RE.sub("", text)
|
|
2084
|
+
|
|
2085
|
+
# Collapse double spaces left over from stripped chars.
|
|
2086
|
+
text = re.sub(r" +", " ", text)
|
|
2087
|
+
|
|
2088
|
+
# Restore protected regions.
|
|
2089
|
+
for key, value in placeholders.items():
|
|
2090
|
+
text = text.replace(key, value)
|
|
2091
|
+
|
|
2092
|
+
return text
|
|
2093
|
+
|
|
2094
|
+
def _resolve_thread_id(
|
|
2095
|
+
self,
|
|
2096
|
+
reply_to: Optional[str],
|
|
2097
|
+
metadata: Optional[Dict[str, Any]],
|
|
2098
|
+
chat_id: Optional[str] = None,
|
|
2099
|
+
) -> Optional[str]:
|
|
2100
|
+
"""Return the Google Chat thread resource name to reply under, or None.
|
|
2101
|
+
|
|
2102
|
+
Priority:
|
|
2103
|
+
1. ``metadata['thread_id']`` — populated by the gateway's session
|
|
2104
|
+
plumbing from ``SessionSource.thread_id`` (the inbound
|
|
2105
|
+
``thread.name``). Canonical path for groups.
|
|
2106
|
+
2. ``metadata['thread_name']`` / ``metadata['thread_ts']`` — Slack
|
|
2107
|
+
precedent aliases that the broader codebase sometimes passes.
|
|
2108
|
+
3. ``reply_to`` if it already looks like a thread resource name
|
|
2109
|
+
(``spaces/X/threads/Y``). Message names ``spaces/X/messages/Y``
|
|
2110
|
+
cannot be converted to threads without an extra API call.
|
|
2111
|
+
4. ``self._last_inbound_thread[chat_id]`` — Google Chat DMs spawn
|
|
2112
|
+
a new thread per top-level user message, and the adapter
|
|
2113
|
+
intentionally drops thread_id from the source so the session
|
|
2114
|
+
key stays stable. Without this fallback, DM replies would
|
|
2115
|
+
land at top-level (a fresh thread separate from the user's),
|
|
2116
|
+
visually disconnected from the user's question.
|
|
2117
|
+
"""
|
|
2118
|
+
if metadata:
|
|
2119
|
+
for key in ("thread_id", "thread_name", "thread_ts"):
|
|
2120
|
+
value = metadata.get(key)
|
|
2121
|
+
if value:
|
|
2122
|
+
return str(value)
|
|
2123
|
+
if reply_to and "/threads/" in reply_to and "/messages/" not in reply_to:
|
|
2124
|
+
return reply_to
|
|
2125
|
+
if chat_id:
|
|
2126
|
+
cached = self._last_inbound_thread.get(chat_id)
|
|
2127
|
+
if cached:
|
|
2128
|
+
return cached
|
|
2129
|
+
return None
|
|
2130
|
+
|
|
2131
|
+
def _new_authed_http(self) -> Any:
|
|
2132
|
+
"""Return a fresh AuthorizedHttp.
|
|
2133
|
+
|
|
2134
|
+
googleapiclient's discovery client is NOT thread-safe because httplib2
|
|
2135
|
+
shares SSL state between calls. Passing a fresh http= to each
|
|
2136
|
+
``execute()`` avoids record-layer failures when calls run in
|
|
2137
|
+
``asyncio.to_thread`` workers. Cheap (~no network).
|
|
2138
|
+
"""
|
|
2139
|
+
return AuthorizedHttp(self._credentials, http=httplib2.Http(timeout=30))
|
|
2140
|
+
|
|
2141
|
+
async def _call_with_retry(
|
|
2142
|
+
self,
|
|
2143
|
+
sync_fn: Callable[[], Any],
|
|
2144
|
+
*,
|
|
2145
|
+
op_name: str = "chat-api-call",
|
|
2146
|
+
) -> Any:
|
|
2147
|
+
"""Run ``sync_fn`` in a thread with bounded retry + jittered backoff.
|
|
2148
|
+
|
|
2149
|
+
Wraps a sync Chat API call (typically a ``.execute()``) so transient
|
|
2150
|
+
429/5xx/timeout failures don't drop user-visible messages. Permanent
|
|
2151
|
+
failures (auth, client errors, validation) bubble up on the first
|
|
2152
|
+
attempt — see :func:`_is_retryable_error`. Cancellation propagates
|
|
2153
|
+
immediately, no extra retries after a CancelledError.
|
|
2154
|
+
|
|
2155
|
+
Pattern lifted from PR #14965.
|
|
2156
|
+
"""
|
|
2157
|
+
delay = _RETRY_BASE_DELAY
|
|
2158
|
+
last_exc: Optional[BaseException] = None
|
|
2159
|
+
for attempt in range(1, _RETRY_MAX_ATTEMPTS + 1):
|
|
2160
|
+
try:
|
|
2161
|
+
return await asyncio.to_thread(sync_fn)
|
|
2162
|
+
except asyncio.CancelledError:
|
|
2163
|
+
raise
|
|
2164
|
+
except Exception as exc:
|
|
2165
|
+
last_exc = exc
|
|
2166
|
+
retryable = _is_retryable_error(exc)
|
|
2167
|
+
if not retryable or attempt >= _RETRY_MAX_ATTEMPTS:
|
|
2168
|
+
raise
|
|
2169
|
+
jitter = delay * _RETRY_JITTER * random.random()
|
|
2170
|
+
wait = min(delay + jitter, _RETRY_MAX_DELAY + _RETRY_JITTER)
|
|
2171
|
+
logger.warning(
|
|
2172
|
+
"[GoogleChat] %s attempt %d/%d failed (%s); "
|
|
2173
|
+
"retrying in %.2fs",
|
|
2174
|
+
op_name, attempt, _RETRY_MAX_ATTEMPTS,
|
|
2175
|
+
_redact_sensitive(str(exc)), wait,
|
|
2176
|
+
)
|
|
2177
|
+
try:
|
|
2178
|
+
await asyncio.sleep(wait)
|
|
2179
|
+
except asyncio.CancelledError:
|
|
2180
|
+
raise
|
|
2181
|
+
delay = min(delay * 2, _RETRY_MAX_DELAY)
|
|
2182
|
+
# Defensive — the loop above always either returns or re-raises.
|
|
2183
|
+
if last_exc is not None:
|
|
2184
|
+
raise last_exc
|
|
2185
|
+
raise RuntimeError(f"{op_name}: retry loop exited without result")
|
|
2186
|
+
|
|
2187
|
+
async def _create_message(
|
|
2188
|
+
self, chat_id: str, body: Dict[str, Any]
|
|
2189
|
+
) -> SendResult:
|
|
2190
|
+
"""POST spaces/{space}/messages via REST, returning SendResult.
|
|
2191
|
+
|
|
2192
|
+
When ``body`` carries ``thread.name``, we MUST pass
|
|
2193
|
+
``messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`` —
|
|
2194
|
+
otherwise Google Chat silently ignores ``thread.name`` and
|
|
2195
|
+
creates a new thread anyway. From the official docs:
|
|
2196
|
+
|
|
2197
|
+
"Default. Starts a new thread. Using this option ignores
|
|
2198
|
+
any thread ID or threadKey that's included."
|
|
2199
|
+
|
|
2200
|
+
See https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/create
|
|
2201
|
+
"""
|
|
2202
|
+
kwargs: Dict[str, Any] = {"parent": chat_id, "body": body}
|
|
2203
|
+
thread_meta = body.get("thread") or {}
|
|
2204
|
+
if thread_meta.get("name"):
|
|
2205
|
+
# FALLBACK_TO_NEW_THREAD: try the requested thread; if Chat
|
|
2206
|
+
# can't route there (e.g. thread no longer exists), create a
|
|
2207
|
+
# new one rather than erroring. Safer than REPLY_MESSAGE_OR_FAIL
|
|
2208
|
+
# for a chat-bot context where stale thread names are rare
|
|
2209
|
+
# but possible.
|
|
2210
|
+
kwargs["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
|
|
2211
|
+
|
|
2212
|
+
def _do_create() -> Dict[str, Any]:
|
|
2213
|
+
return (
|
|
2214
|
+
self._chat_api.spaces()
|
|
2215
|
+
.messages()
|
|
2216
|
+
.create(**kwargs)
|
|
2217
|
+
.execute(http=self._new_authed_http())
|
|
2218
|
+
)
|
|
2219
|
+
|
|
2220
|
+
resp = await self._call_with_retry(_do_create, op_name="messages.create")
|
|
2221
|
+
# Track outbound destination thread in the persistent count store
|
|
2222
|
+
# so a future user "Reply in thread" on the bot's message resolves
|
|
2223
|
+
# to a known thread (prev_count >= 1 → side thread). Without
|
|
2224
|
+
# this, threads created by the bot's own outbound look fresh
|
|
2225
|
+
# the first time the user engages them, and the heuristic
|
|
2226
|
+
# incorrectly classifies the engagement as main-flow → bot
|
|
2227
|
+
# replies at top-level instead of in the thread.
|
|
2228
|
+
resp_thread = (resp.get("thread") or {}).get("name") or ""
|
|
2229
|
+
if chat_id and resp_thread:
|
|
2230
|
+
try:
|
|
2231
|
+
self._thread_count_store.incr(chat_id, resp_thread)
|
|
2232
|
+
except Exception:
|
|
2233
|
+
logger.debug(
|
|
2234
|
+
"[GoogleChat] outbound thread-count incr failed",
|
|
2235
|
+
exc_info=True,
|
|
2236
|
+
)
|
|
2237
|
+
return SendResult(success=True, message_id=resp.get("name"))
|
|
2238
|
+
|
|
2239
|
+
async def send_typing(self, chat_id: str, metadata: Any = None) -> None:
|
|
2240
|
+
"""Post a visible 'Hermes is thinking…' marker message.
|
|
2241
|
+
|
|
2242
|
+
NOT ephemeral (Google Chat has no ephemeral text messages outside
|
|
2243
|
+
slash command responses). ``send()`` PATCHes this marker in-place
|
|
2244
|
+
with the real response (no deletion tombstone). The typing card is
|
|
2245
|
+
either patched by ``send()`` (success) or by
|
|
2246
|
+
``on_processing_complete`` (failure / cancellation).
|
|
2247
|
+
|
|
2248
|
+
IMPORTANT — must place the typing card in the user's thread:
|
|
2249
|
+
``messages.patch`` cannot change a message's ``thread`` (it's
|
|
2250
|
+
immutable on update). If we create the typing card at top-level
|
|
2251
|
+
and the user is replying inside thread T, send() will patch the
|
|
2252
|
+
top-level card in place — leaving the bot's whole response
|
|
2253
|
+
stranded outside the user's thread. We resolve the thread the
|
|
2254
|
+
same way send() does.
|
|
2255
|
+
|
|
2256
|
+
IMPORTANT — cancellation safety:
|
|
2257
|
+
``base.py``'s ``_keep_typing`` calls this through
|
|
2258
|
+
``asyncio.wait_for(send_typing, timeout=1.5)``. When the
|
|
2259
|
+
create-API call takes longer than 1.5s, ``wait_for`` cancels
|
|
2260
|
+
``send_typing`` mid-flight — but the underlying ``asyncio.to_thread``
|
|
2261
|
+
keeps running and creates a card in Chat that we have NO way to
|
|
2262
|
+
track (the storage line never runs). Next ``_keep_typing`` tick
|
|
2263
|
+
sees an empty slot and creates a SECOND card. Result: one orphan
|
|
2264
|
+
"Hermes is thinking…" stuck in chat forever, plus one card that
|
|
2265
|
+
gets patched into the reply.
|
|
2266
|
+
|
|
2267
|
+
Fix: reserve the slot with an in-flight ``Event``, run the
|
|
2268
|
+
create in a background task, and ``await asyncio.shield`` it.
|
|
2269
|
+
Cancellation of THIS coroutine no longer cancels the create —
|
|
2270
|
+
the task runs to completion and the msg_id lands in the slot
|
|
2271
|
+
regardless.
|
|
2272
|
+
"""
|
|
2273
|
+
# Already have a card (real msg_id, sentinel, or in-flight) — bail.
|
|
2274
|
+
if chat_id in self._typing_messages:
|
|
2275
|
+
return
|
|
2276
|
+
if chat_id in self._typing_card_inflight:
|
|
2277
|
+
# Another create is already running for this chat. Wait for
|
|
2278
|
+
# it to finish so we honor the contract "if called, the card
|
|
2279
|
+
# is up by the time we return". Bounded wait — if the
|
|
2280
|
+
# background task is stuck, _keep_typing will retry.
|
|
2281
|
+
try:
|
|
2282
|
+
await asyncio.wait_for(
|
|
2283
|
+
self._typing_card_inflight[chat_id].wait(),
|
|
2284
|
+
timeout=5.0,
|
|
2285
|
+
)
|
|
2286
|
+
except (asyncio.TimeoutError, KeyError):
|
|
2287
|
+
pass
|
|
2288
|
+
return
|
|
2289
|
+
|
|
2290
|
+
thread_id = self._resolve_thread_id(
|
|
2291
|
+
reply_to=None, metadata=metadata, chat_id=chat_id,
|
|
2292
|
+
)
|
|
2293
|
+
body: Dict[str, Any] = {"text": "Hermes is thinking…"}
|
|
2294
|
+
if thread_id:
|
|
2295
|
+
body["thread"] = {"name": thread_id}
|
|
2296
|
+
|
|
2297
|
+
completed = asyncio.Event()
|
|
2298
|
+
self._typing_card_inflight[chat_id] = completed
|
|
2299
|
+
|
|
2300
|
+
async def _create_and_record() -> None:
|
|
2301
|
+
try:
|
|
2302
|
+
result = await self._create_message(chat_id, body)
|
|
2303
|
+
if result.success and result.message_id:
|
|
2304
|
+
# Only overwrite the slot if nothing else has claimed it
|
|
2305
|
+
# in the meantime (e.g. send() racing ahead of us).
|
|
2306
|
+
if chat_id not in self._typing_messages:
|
|
2307
|
+
self._typing_messages[chat_id] = result.message_id
|
|
2308
|
+
else:
|
|
2309
|
+
# Slot already populated — likely send() patched
|
|
2310
|
+
# something or another create completed first.
|
|
2311
|
+
# Our card is ORPHANED here, but at least it's a
|
|
2312
|
+
# known orphan we can clean up at end of turn.
|
|
2313
|
+
# Track for cleanup by on_processing_complete.
|
|
2314
|
+
self._orphan_typing_messages.setdefault(
|
|
2315
|
+
chat_id, []
|
|
2316
|
+
).append(result.message_id)
|
|
2317
|
+
except Exception:
|
|
2318
|
+
logger.debug(
|
|
2319
|
+
"[GoogleChat] send_typing background create failed",
|
|
2320
|
+
exc_info=True,
|
|
2321
|
+
)
|
|
2322
|
+
finally:
|
|
2323
|
+
self._typing_card_inflight.pop(chat_id, None)
|
|
2324
|
+
completed.set()
|
|
2325
|
+
|
|
2326
|
+
task = asyncio.create_task(_create_and_record())
|
|
2327
|
+
# Shield the task from cancellation of our awaiter. If
|
|
2328
|
+
# _keep_typing's wait_for times out, our coroutine is cancelled
|
|
2329
|
+
# but the task continues in the background — so the msg_id
|
|
2330
|
+
# eventually lands in the slot even when the API call is slow.
|
|
2331
|
+
try:
|
|
2332
|
+
await asyncio.shield(task)
|
|
2333
|
+
except asyncio.CancelledError:
|
|
2334
|
+
# The shielded task keeps running. Re-raise so the caller's
|
|
2335
|
+
# cancellation semantics are preserved.
|
|
2336
|
+
raise
|
|
2337
|
+
|
|
2338
|
+
async def stop_typing(self, chat_id: str) -> None:
|
|
2339
|
+
"""Stop the typing indicator — NO-OP when a live card is tracked.
|
|
2340
|
+
|
|
2341
|
+
Google Chat has no separate typing API: the "Hermes is thinking…"
|
|
2342
|
+
marker is a real message that ``send()`` patches in-place with the
|
|
2343
|
+
agent's reply. Deleting the marker creates a "Message deleted by
|
|
2344
|
+
its author" tombstone, which is visual noise.
|
|
2345
|
+
|
|
2346
|
+
Upstream code (gateway/run.py and gateway/platforms/base.py) calls
|
|
2347
|
+
``stop_typing`` at three moments per turn — typically BEFORE
|
|
2348
|
+
``send()`` runs (so deleting the slot would leave ``send()``
|
|
2349
|
+
nothing to patch, forcing it to create a fresh message and leaving
|
|
2350
|
+
the original card as a tombstone). To fix this without modifying
|
|
2351
|
+
upstream contracts, ``stop_typing`` here is intentionally a NO-OP
|
|
2352
|
+
when the slot holds a real ``message_name``: the card is left in
|
|
2353
|
+
place so ``send()`` can patch it.
|
|
2354
|
+
|
|
2355
|
+
Three cases:
|
|
2356
|
+
* Slot empty → nothing to do.
|
|
2357
|
+
* Slot holds SENTINEL → ``send()`` already patched the card;
|
|
2358
|
+
pop the sentinel so the next turn starts clean.
|
|
2359
|
+
* Slot holds a real ``message_name`` → leave it for ``send()``
|
|
2360
|
+
to consume. NO-OP.
|
|
2361
|
+
|
|
2362
|
+
Stranded cards on error / cancellation paths (where ``send()``
|
|
2363
|
+
never runs) are reaped by ``on_processing_complete`` — see that
|
|
2364
|
+
hook for the patch-to-final-state cleanup.
|
|
2365
|
+
"""
|
|
2366
|
+
current = self._typing_messages.get(chat_id)
|
|
2367
|
+
if not current:
|
|
2368
|
+
return
|
|
2369
|
+
if current == _TYPING_CONSUMED_SENTINEL:
|
|
2370
|
+
self._typing_messages.pop(chat_id, None)
|
|
2371
|
+
return
|
|
2372
|
+
# Real message_name — leave it for send() to patch. Deliberate no-op.
|
|
2373
|
+
return
|
|
2374
|
+
|
|
2375
|
+
async def on_processing_complete(
|
|
2376
|
+
self, event: MessageEvent, outcome: ProcessingOutcome
|
|
2377
|
+
) -> None:
|
|
2378
|
+
"""Reap typing card(s) after the message-handling cycle ends.
|
|
2379
|
+
|
|
2380
|
+
SUCCESS: ``send()`` set the SENTINEL after patching. Pop it.
|
|
2381
|
+
|
|
2382
|
+
FAILURE / CANCELLED: ``send()`` may not have run, leaving a real
|
|
2383
|
+
``message_name`` in the slot. Patching the card to a final state
|
|
2384
|
+
(``"(interrupted)"``) avoids the tombstone that ``messages.delete``
|
|
2385
|
+
would create. If ``send()`` did run (e.g. base.py error-send branch
|
|
2386
|
+
patched it), the slot holds the SENTINEL — pop and exit.
|
|
2387
|
+
|
|
2388
|
+
Orphan cards: when a background ``send_typing`` task creates a
|
|
2389
|
+
card AFTER ``send()`` already populated the slot (race window
|
|
2390
|
+
when the API call takes longer than _keep_typing's wait_for
|
|
2391
|
+
timeout), the orphan id is stashed in ``self._orphan_typing_messages``.
|
|
2392
|
+
Patch each orphan with an empty-ish marker so the user doesn't
|
|
2393
|
+
see "Hermes is thinking…" stuck forever.
|
|
2394
|
+
"""
|
|
2395
|
+
if event.source is None:
|
|
2396
|
+
return
|
|
2397
|
+
chat_id = event.source.chat_id
|
|
2398
|
+
try:
|
|
2399
|
+
current = self._typing_messages.pop(chat_id, None)
|
|
2400
|
+
if current and current != _TYPING_CONSUMED_SENTINEL:
|
|
2401
|
+
# Real message_name still in slot — send() never ran. Patch
|
|
2402
|
+
# with a benign final state instead of deleting (no tombstone).
|
|
2403
|
+
label = (
|
|
2404
|
+
"(interrupted)" if outcome == ProcessingOutcome.CANCELLED
|
|
2405
|
+
else "(no reply)"
|
|
2406
|
+
)
|
|
2407
|
+
try:
|
|
2408
|
+
await self._patch_message(current, {"text": label})
|
|
2409
|
+
except Exception:
|
|
2410
|
+
logger.debug(
|
|
2411
|
+
"[GoogleChat] on_processing_complete patch fallback failed",
|
|
2412
|
+
exc_info=True,
|
|
2413
|
+
)
|
|
2414
|
+
# Reap orphan typing cards (background creates that lost a
|
|
2415
|
+
# race with send()). Patch them to a single dot so they
|
|
2416
|
+
# gracefully retire — the user already saw the real reply
|
|
2417
|
+
# in another card, this one is just visual noise to clear.
|
|
2418
|
+
orphans = self._orphan_typing_messages.pop(chat_id, [])
|
|
2419
|
+
for orphan_id in orphans:
|
|
2420
|
+
try:
|
|
2421
|
+
await self._patch_message(orphan_id, {"text": "·"})
|
|
2422
|
+
except Exception:
|
|
2423
|
+
logger.debug(
|
|
2424
|
+
"[GoogleChat] orphan typing-card patch failed: %s",
|
|
2425
|
+
orphan_id, exc_info=True,
|
|
2426
|
+
)
|
|
2427
|
+
except Exception:
|
|
2428
|
+
logger.debug(
|
|
2429
|
+
"[GoogleChat] cleanup in on_processing_complete failed", exc_info=True
|
|
2430
|
+
)
|
|
2431
|
+
|
|
2432
|
+
# ------------------------------------------------------------------
|
|
2433
|
+
# Attachment send paths
|
|
2434
|
+
# ------------------------------------------------------------------
|
|
2435
|
+
async def _consume_typing_card_with_text(
|
|
2436
|
+
self, chat_id: str, text: str
|
|
2437
|
+
) -> Optional[SendResult]:
|
|
2438
|
+
"""Patch the tracked typing card with ``text`` (no tombstone).
|
|
2439
|
+
|
|
2440
|
+
Returns ``None`` if there's no real typing card to patch (caller
|
|
2441
|
+
should create a new message). Returns the patch result if the
|
|
2442
|
+
card was successfully patched. Raises on transient HttpErrors so
|
|
2443
|
+
the caller can decide whether to fall back to ``_create_message``.
|
|
2444
|
+
|
|
2445
|
+
Leaves the SENTINEL in place when present: a previous ``send()``
|
|
2446
|
+
already consumed the typing card, and the SENTINEL must stay in
|
|
2447
|
+
the slot to keep the base class's ``_keep_typing`` loop from
|
|
2448
|
+
creating a fresh "Hermes is thinking…" card during any subsequent
|
|
2449
|
+
attachment send (which would later be reaped as "(no reply)").
|
|
2450
|
+
"""
|
|
2451
|
+
current = self._typing_messages.get(chat_id)
|
|
2452
|
+
if not current or current == _TYPING_CONSUMED_SENTINEL:
|
|
2453
|
+
return None
|
|
2454
|
+
# Real msg_id — pop and patch.
|
|
2455
|
+
self._typing_messages.pop(chat_id, None)
|
|
2456
|
+
try:
|
|
2457
|
+
result = await self._patch_message(current, {"text": text})
|
|
2458
|
+
self._typing_messages[chat_id] = _TYPING_CONSUMED_SENTINEL
|
|
2459
|
+
return result
|
|
2460
|
+
except HttpError as exc:
|
|
2461
|
+
status = getattr(getattr(exc, "resp", None), "status", None)
|
|
2462
|
+
if status == 404:
|
|
2463
|
+
# Card disappeared — caller should create a new message.
|
|
2464
|
+
return None
|
|
2465
|
+
raise
|
|
2466
|
+
|
|
2467
|
+
async def send_image(
|
|
2468
|
+
self,
|
|
2469
|
+
chat_id: str,
|
|
2470
|
+
image_url: str,
|
|
2471
|
+
caption: Optional[str] = None,
|
|
2472
|
+
reply_to: Optional[str] = None,
|
|
2473
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
2474
|
+
) -> SendResult:
|
|
2475
|
+
"""Send an inline image via attachment URL (no upload).
|
|
2476
|
+
|
|
2477
|
+
If a typing card is tracked for this chat, patch it in-place with
|
|
2478
|
+
the image (caption + URL) — same anti-tombstone pattern used by
|
|
2479
|
+
``send()``. Otherwise create a new message.
|
|
2480
|
+
"""
|
|
2481
|
+
thread_id = self._resolve_thread_id(reply_to, metadata, chat_id=chat_id)
|
|
2482
|
+
text_parts: List[str] = []
|
|
2483
|
+
if caption:
|
|
2484
|
+
text_parts.append(caption)
|
|
2485
|
+
text_parts.append(image_url)
|
|
2486
|
+
text = "\n".join(text_parts)
|
|
2487
|
+
|
|
2488
|
+
try:
|
|
2489
|
+
patched = await self._consume_typing_card_with_text(chat_id, text)
|
|
2490
|
+
if patched is not None:
|
|
2491
|
+
return patched
|
|
2492
|
+
body: Dict[str, Any] = {"text": text}
|
|
2493
|
+
if thread_id:
|
|
2494
|
+
body["thread"] = {"name": thread_id}
|
|
2495
|
+
return await self._create_message(chat_id, body)
|
|
2496
|
+
except HttpError as exc:
|
|
2497
|
+
return SendResult(success=False, error=_redact_sensitive(str(exc)))
|
|
2498
|
+
|
|
2499
|
+
async def send_image_file(
|
|
2500
|
+
self,
|
|
2501
|
+
chat_id: str,
|
|
2502
|
+
image_path: str,
|
|
2503
|
+
caption: Optional[str] = None,
|
|
2504
|
+
reply_to: Optional[str] = None,
|
|
2505
|
+
**kwargs: Any,
|
|
2506
|
+
) -> SendResult:
|
|
2507
|
+
return await self._send_file(
|
|
2508
|
+
chat_id, image_path, caption,
|
|
2509
|
+
mime_hint="image/*",
|
|
2510
|
+
thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id),
|
|
2511
|
+
)
|
|
2512
|
+
|
|
2513
|
+
async def send_document(
|
|
2514
|
+
self,
|
|
2515
|
+
chat_id: str,
|
|
2516
|
+
file_path: str,
|
|
2517
|
+
caption: Optional[str] = None,
|
|
2518
|
+
file_name: Optional[str] = None,
|
|
2519
|
+
reply_to: Optional[str] = None,
|
|
2520
|
+
**kwargs: Any,
|
|
2521
|
+
) -> SendResult:
|
|
2522
|
+
return await self._send_file(
|
|
2523
|
+
chat_id, file_path, caption,
|
|
2524
|
+
mime_hint=None,
|
|
2525
|
+
thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id),
|
|
2526
|
+
override_filename=file_name,
|
|
2527
|
+
)
|
|
2528
|
+
|
|
2529
|
+
async def send_voice(
|
|
2530
|
+
self,
|
|
2531
|
+
chat_id: str,
|
|
2532
|
+
audio_path: str,
|
|
2533
|
+
caption: Optional[str] = None,
|
|
2534
|
+
reply_to: Optional[str] = None,
|
|
2535
|
+
**kwargs: Any,
|
|
2536
|
+
) -> SendResult:
|
|
2537
|
+
return await self._send_file(
|
|
2538
|
+
chat_id, audio_path, caption,
|
|
2539
|
+
mime_hint="audio/ogg",
|
|
2540
|
+
thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id),
|
|
2541
|
+
)
|
|
2542
|
+
|
|
2543
|
+
async def send_video(
|
|
2544
|
+
self,
|
|
2545
|
+
chat_id: str,
|
|
2546
|
+
video_path: str,
|
|
2547
|
+
caption: Optional[str] = None,
|
|
2548
|
+
reply_to: Optional[str] = None,
|
|
2549
|
+
**kwargs: Any,
|
|
2550
|
+
) -> SendResult:
|
|
2551
|
+
return await self._send_file(
|
|
2552
|
+
chat_id, video_path, caption,
|
|
2553
|
+
mime_hint="video/mp4",
|
|
2554
|
+
thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id),
|
|
2555
|
+
)
|
|
2556
|
+
|
|
2557
|
+
async def send_animation(
|
|
2558
|
+
self,
|
|
2559
|
+
chat_id: str,
|
|
2560
|
+
animation_url: str,
|
|
2561
|
+
caption: Optional[str] = None,
|
|
2562
|
+
reply_to: Optional[str] = None,
|
|
2563
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
2564
|
+
) -> SendResult:
|
|
2565
|
+
"""Google Chat has no native animation type; fall back to send_image."""
|
|
2566
|
+
return await self.send_image(
|
|
2567
|
+
chat_id, animation_url, caption=caption,
|
|
2568
|
+
reply_to=reply_to, metadata=metadata,
|
|
2569
|
+
)
|
|
2570
|
+
|
|
2571
|
+
# ------------------------------------------------------------------
|
|
2572
|
+
# Native attachment delivery via user OAuth
|
|
2573
|
+
#
|
|
2574
|
+
# Google Chat's media.upload endpoint hard-rejects SA authentication
|
|
2575
|
+
# ("This method doesn't support app authentication with a service
|
|
2576
|
+
# account"). The bot itself cannot upload files. Instead the user
|
|
2577
|
+
# grants the bot the chat.messages.create scope ONCE via an in-chat
|
|
2578
|
+
# OAuth consent flow (``/setup-files``); the resulting refresh token
|
|
2579
|
+
# lets the bot call media.upload AS the user, producing native Chat
|
|
2580
|
+
# attachments (file widget, inline preview, click-to-download).
|
|
2581
|
+
#
|
|
2582
|
+
# See https://developers.google.com/chat/api/guides/auth/users for
|
|
2583
|
+
# the upstream limitation that makes user OAuth necessary, and
|
|
2584
|
+
# ``plugins/platforms/google_chat/oauth.py`` for the helper
|
|
2585
|
+
# script + library functions backing this path.
|
|
2586
|
+
# ------------------------------------------------------------------
|
|
2587
|
+
@staticmethod
|
|
2588
|
+
def _is_app_auth_attachment_error(exc: HttpError) -> bool:
|
|
2589
|
+
"""Detect Google Chat's media.upload bot-auth rejection.
|
|
2590
|
+
|
|
2591
|
+
Returns True for the canonical ``"doesn't support app
|
|
2592
|
+
authentication"`` wording (and the legacy
|
|
2593
|
+
``ACCESS_TOKEN_SCOPE_INSUFFICIENT`` variant some older clients
|
|
2594
|
+
still see). Used to flag a misuse — calling ``media.upload``
|
|
2595
|
+
through the SA-authed Chat API client instead of the user-authed
|
|
2596
|
+
one. With correct routing this error should never fire in the
|
|
2597
|
+
adapter; it remains as a defensive check.
|
|
2598
|
+
"""
|
|
2599
|
+
text = str(exc) or ""
|
|
2600
|
+
return (
|
|
2601
|
+
"doesn't support app authentication" in text
|
|
2602
|
+
or "ACCESS_TOKEN_SCOPE_INSUFFICIENT" in text
|
|
2603
|
+
)
|
|
2604
|
+
|
|
2605
|
+
_LEGACY_USER_IDENTITY = "__legacy__"
|
|
2606
|
+
|
|
2607
|
+
async def _load_per_user_chat_api(self, email: str) -> Optional[Any]:
|
|
2608
|
+
"""Get (or build + cache) a user-authed Chat client for ``email``.
|
|
2609
|
+
|
|
2610
|
+
Hits ``self._user_chat_api_by_email`` first; on miss, loads the
|
|
2611
|
+
per-user token from disk, refreshes if needed, builds an API
|
|
2612
|
+
client, and caches both. Refresh failures evict the slot so the
|
|
2613
|
+
next request goes back through the disk path (and ultimately the
|
|
2614
|
+
text-notice fallback if the user has revoked).
|
|
2615
|
+
"""
|
|
2616
|
+
from .oauth import (
|
|
2617
|
+
load_user_credentials as _load,
|
|
2618
|
+
build_user_chat_service as _build,
|
|
2619
|
+
refresh_or_none as _refresh,
|
|
2620
|
+
)
|
|
2621
|
+
|
|
2622
|
+
cached_api = self._user_chat_api_by_email.get(email)
|
|
2623
|
+
cached_creds = self._user_creds_by_email.get(email)
|
|
2624
|
+
if cached_api is not None and cached_creds is not None:
|
|
2625
|
+
try:
|
|
2626
|
+
refreshed = await asyncio.to_thread(_refresh, cached_creds, email)
|
|
2627
|
+
except Exception:
|
|
2628
|
+
logger.debug(
|
|
2629
|
+
"[GoogleChat] cached per-user refresh raised", exc_info=True,
|
|
2630
|
+
)
|
|
2631
|
+
refreshed = None
|
|
2632
|
+
if refreshed is None:
|
|
2633
|
+
self._user_chat_api_by_email.pop(email, None)
|
|
2634
|
+
self._user_creds_by_email.pop(email, None)
|
|
2635
|
+
return None
|
|
2636
|
+
self._user_creds_by_email[email] = refreshed
|
|
2637
|
+
return cached_api
|
|
2638
|
+
|
|
2639
|
+
try:
|
|
2640
|
+
creds = await asyncio.to_thread(_load, email)
|
|
2641
|
+
if creds is None:
|
|
2642
|
+
return None
|
|
2643
|
+
api = await asyncio.to_thread(lambda: _build(creds))
|
|
2644
|
+
except Exception:
|
|
2645
|
+
logger.debug(
|
|
2646
|
+
"[GoogleChat] per-user creds load/build failed for %s",
|
|
2647
|
+
email, exc_info=True,
|
|
2648
|
+
)
|
|
2649
|
+
return None
|
|
2650
|
+
|
|
2651
|
+
self._user_creds_by_email[email] = creds
|
|
2652
|
+
self._user_chat_api_by_email[email] = api
|
|
2653
|
+
return api
|
|
2654
|
+
|
|
2655
|
+
async def _acquire_user_chat_api(
|
|
2656
|
+
self, sender_email: Optional[str]
|
|
2657
|
+
) -> Tuple[Optional[Any], Optional[str]]:
|
|
2658
|
+
"""Resolve the user-authed Chat client for an outbound attachment.
|
|
2659
|
+
|
|
2660
|
+
Lookup order:
|
|
2661
|
+
1. Per-user token for ``sender_email`` — the asker's identity.
|
|
2662
|
+
2. Legacy single-user fallback (``self._user_chat_api``) for
|
|
2663
|
+
pre-multi-user installs.
|
|
2664
|
+
3. None — caller posts the setup-instructions text notice.
|
|
2665
|
+
|
|
2666
|
+
Returns ``(client, identity_label)`` where ``identity_label`` is
|
|
2667
|
+
the sanitized email or the literal ``"__legacy__"`` sentinel.
|
|
2668
|
+
``_invalidate_user_creds`` uses the label to evict the right slot
|
|
2669
|
+
on auth failure.
|
|
2670
|
+
"""
|
|
2671
|
+
if sender_email:
|
|
2672
|
+
api = await self._load_per_user_chat_api(sender_email)
|
|
2673
|
+
if api is not None:
|
|
2674
|
+
return api, sender_email
|
|
2675
|
+
|
|
2676
|
+
if self._user_chat_api is not None:
|
|
2677
|
+
try:
|
|
2678
|
+
from .oauth import (
|
|
2679
|
+
refresh_or_none as _refresh,
|
|
2680
|
+
)
|
|
2681
|
+
refreshed = await asyncio.to_thread(
|
|
2682
|
+
_refresh, self._user_credentials, None,
|
|
2683
|
+
)
|
|
2684
|
+
except Exception:
|
|
2685
|
+
logger.debug(
|
|
2686
|
+
"[GoogleChat] legacy creds refresh raised", exc_info=True,
|
|
2687
|
+
)
|
|
2688
|
+
refreshed = None
|
|
2689
|
+
if refreshed is None:
|
|
2690
|
+
logger.warning(
|
|
2691
|
+
"[GoogleChat] legacy user-OAuth refresh returned None — "
|
|
2692
|
+
"evicting fallback creds"
|
|
2693
|
+
)
|
|
2694
|
+
self._user_credentials = None
|
|
2695
|
+
self._user_chat_api = None
|
|
2696
|
+
return None, None
|
|
2697
|
+
self._user_credentials = refreshed
|
|
2698
|
+
return self._user_chat_api, self._LEGACY_USER_IDENTITY
|
|
2699
|
+
|
|
2700
|
+
return None, None
|
|
2701
|
+
|
|
2702
|
+
def _invalidate_user_creds(self, identity: Optional[str]) -> None:
|
|
2703
|
+
"""Drop creds for ``identity`` after an auth failure.
|
|
2704
|
+
|
|
2705
|
+
``identity`` comes from ``_acquire_user_chat_api`` — either the
|
|
2706
|
+
sender email (per-user slot) or ``__legacy__`` for the fallback
|
|
2707
|
+
slot. None is a no-op.
|
|
2708
|
+
"""
|
|
2709
|
+
if not identity:
|
|
2710
|
+
return
|
|
2711
|
+
if identity == self._LEGACY_USER_IDENTITY:
|
|
2712
|
+
self._user_credentials = None
|
|
2713
|
+
self._user_chat_api = None
|
|
2714
|
+
return
|
|
2715
|
+
self._user_creds_by_email.pop(identity, None)
|
|
2716
|
+
self._user_chat_api_by_email.pop(identity, None)
|
|
2717
|
+
|
|
2718
|
+
async def _send_file(
|
|
2719
|
+
self,
|
|
2720
|
+
chat_id: str,
|
|
2721
|
+
path: str,
|
|
2722
|
+
caption: Optional[str],
|
|
2723
|
+
mime_hint: Optional[str],
|
|
2724
|
+
thread_id: Optional[str] = None,
|
|
2725
|
+
override_filename: Optional[str] = None,
|
|
2726
|
+
) -> SendResult:
|
|
2727
|
+
"""Native Chat attachment via user-OAuth media.upload.
|
|
2728
|
+
|
|
2729
|
+
Two-step on the wire: ``media.upload`` then
|
|
2730
|
+
``spaces.messages.create`` with the returned ``attachmentDataRef``.
|
|
2731
|
+
BOTH calls go through a user-authed Chat API client — the
|
|
2732
|
+
SA-authed client is rejected by ``media.upload`` regardless of
|
|
2733
|
+
scopes.
|
|
2734
|
+
|
|
2735
|
+
Multi-user routing: the bot looks up the most recent inbound
|
|
2736
|
+
sender for this ``chat_id`` and uses THAT user's stored OAuth
|
|
2737
|
+
token. Falls back to a legacy single-user token when present
|
|
2738
|
+
(for pre-multi-user installs), and to a setup-instructions text
|
|
2739
|
+
notice when neither is available.
|
|
2740
|
+
|
|
2741
|
+
Google Chat ``messages.patch`` cannot add an attachment to an
|
|
2742
|
+
existing message, so we cannot transform the typing card directly
|
|
2743
|
+
into the file message. Instead we patch the typing card with the
|
|
2744
|
+
caption (or a single space when none) so it retires without a
|
|
2745
|
+
tombstone, then create the attachment message.
|
|
2746
|
+
"""
|
|
2747
|
+
if not os.path.exists(path):
|
|
2748
|
+
return SendResult(success=False, error=f"file not found: {path}")
|
|
2749
|
+
|
|
2750
|
+
filename = override_filename or os.path.basename(path) or "upload.bin"
|
|
2751
|
+
mime = mime_hint or "application/octet-stream"
|
|
2752
|
+
|
|
2753
|
+
sender_email = self._last_sender_by_chat.get(chat_id)
|
|
2754
|
+
chat_api, identity = await self._acquire_user_chat_api(sender_email)
|
|
2755
|
+
|
|
2756
|
+
# No user OAuth → can't upload natively. Surface clear setup
|
|
2757
|
+
# instructions in chat instead of silently failing.
|
|
2758
|
+
if chat_api is None:
|
|
2759
|
+
return await self._post_attachment_fallback(
|
|
2760
|
+
chat_id=chat_id,
|
|
2761
|
+
path=path,
|
|
2762
|
+
filename=filename,
|
|
2763
|
+
caption=caption,
|
|
2764
|
+
thread_id=thread_id,
|
|
2765
|
+
)
|
|
2766
|
+
|
|
2767
|
+
# Pre-patch the typing card with the caption (or single space) so
|
|
2768
|
+
# it retires without a tombstone before the attachment message is
|
|
2769
|
+
# posted.
|
|
2770
|
+
try:
|
|
2771
|
+
await self._consume_typing_card_with_text(chat_id, caption or " ")
|
|
2772
|
+
except Exception:
|
|
2773
|
+
logger.debug(
|
|
2774
|
+
"[GoogleChat] _send_file pre-patch typing-card failed",
|
|
2775
|
+
exc_info=True,
|
|
2776
|
+
)
|
|
2777
|
+
|
|
2778
|
+
def _upload() -> Dict[str, Any]:
|
|
2779
|
+
media = MediaFileUpload(path, mimetype=mime, resumable=False)
|
|
2780
|
+
return (
|
|
2781
|
+
chat_api.media()
|
|
2782
|
+
.upload(
|
|
2783
|
+
parent=chat_id,
|
|
2784
|
+
body={"filename": filename},
|
|
2785
|
+
media_body=media,
|
|
2786
|
+
)
|
|
2787
|
+
.execute()
|
|
2788
|
+
)
|
|
2789
|
+
|
|
2790
|
+
try:
|
|
2791
|
+
upload_resp = await asyncio.to_thread(_upload)
|
|
2792
|
+
except HttpError as exc:
|
|
2793
|
+
status = getattr(getattr(exc, "resp", None), "status", None)
|
|
2794
|
+
if status in (401, 403):
|
|
2795
|
+
logger.warning(
|
|
2796
|
+
"[GoogleChat] media.upload auth failure for identity=%s "
|
|
2797
|
+
"(token revoked or scope missing) — falling back to "
|
|
2798
|
+
"text notice. Status=%s", identity, status,
|
|
2799
|
+
)
|
|
2800
|
+
self._invalidate_user_creds(identity)
|
|
2801
|
+
return await self._post_attachment_fallback(
|
|
2802
|
+
chat_id=chat_id,
|
|
2803
|
+
path=path,
|
|
2804
|
+
filename=filename,
|
|
2805
|
+
caption=caption,
|
|
2806
|
+
thread_id=thread_id,
|
|
2807
|
+
)
|
|
2808
|
+
return SendResult(
|
|
2809
|
+
success=False, error=_redact_sensitive(str(exc))
|
|
2810
|
+
)
|
|
2811
|
+
|
|
2812
|
+
attachment_ref = upload_resp.get("attachmentDataRef")
|
|
2813
|
+
if not attachment_ref:
|
|
2814
|
+
return SendResult(
|
|
2815
|
+
success=False,
|
|
2816
|
+
error="upload returned no attachmentDataRef",
|
|
2817
|
+
)
|
|
2818
|
+
|
|
2819
|
+
body: Dict[str, Any] = {
|
|
2820
|
+
"attachment": [{"attachmentDataRef": attachment_ref}],
|
|
2821
|
+
}
|
|
2822
|
+
if caption:
|
|
2823
|
+
body["text"] = caption
|
|
2824
|
+
if thread_id:
|
|
2825
|
+
body["thread"] = {"name": thread_id}
|
|
2826
|
+
|
|
2827
|
+
# The accompanying messages.create that references the attachment
|
|
2828
|
+
# also needs user auth (the attachmentDataRef is bound to the
|
|
2829
|
+
# uploading principal). messageReplyOption is required for the
|
|
2830
|
+
# thread.name in body to actually be honored — see
|
|
2831
|
+
# _create_message docstring for the API quirk.
|
|
2832
|
+
create_kwargs: Dict[str, Any] = {"parent": chat_id, "body": body}
|
|
2833
|
+
if thread_id:
|
|
2834
|
+
create_kwargs["messageReplyOption"] = (
|
|
2835
|
+
"REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
|
|
2836
|
+
)
|
|
2837
|
+
|
|
2838
|
+
def _create_with_attachment() -> Dict[str, Any]:
|
|
2839
|
+
return (
|
|
2840
|
+
chat_api.spaces()
|
|
2841
|
+
.messages()
|
|
2842
|
+
.create(**create_kwargs)
|
|
2843
|
+
.execute()
|
|
2844
|
+
)
|
|
2845
|
+
|
|
2846
|
+
try:
|
|
2847
|
+
resp = await asyncio.to_thread(_create_with_attachment)
|
|
2848
|
+
# Track outbound destination thread (see _create_message
|
|
2849
|
+
# comment for why — same reasoning applies to the
|
|
2850
|
+
# user-OAuth attachment path).
|
|
2851
|
+
resp_thread = (resp.get("thread") or {}).get("name") or ""
|
|
2852
|
+
if chat_id and resp_thread:
|
|
2853
|
+
try:
|
|
2854
|
+
self._thread_count_store.incr(chat_id, resp_thread)
|
|
2855
|
+
except Exception:
|
|
2856
|
+
logger.debug(
|
|
2857
|
+
"[GoogleChat] outbound thread-count incr failed",
|
|
2858
|
+
exc_info=True,
|
|
2859
|
+
)
|
|
2860
|
+
return SendResult(
|
|
2861
|
+
success=True, message_id=resp.get("name"),
|
|
2862
|
+
)
|
|
2863
|
+
except HttpError as exc:
|
|
2864
|
+
return SendResult(
|
|
2865
|
+
success=False, error=_redact_sensitive(str(exc))
|
|
2866
|
+
)
|
|
2867
|
+
|
|
2868
|
+
async def _post_attachment_fallback(
|
|
2869
|
+
self,
|
|
2870
|
+
chat_id: str,
|
|
2871
|
+
path: str,
|
|
2872
|
+
filename: str,
|
|
2873
|
+
caption: Optional[str],
|
|
2874
|
+
thread_id: Optional[str],
|
|
2875
|
+
) -> SendResult:
|
|
2876
|
+
"""Post a text notice when native attachment delivery is unavailable.
|
|
2877
|
+
|
|
2878
|
+
Tells the user that file delivery requires a one-time consent
|
|
2879
|
+
flow (``/setup-files``) and reports the local-host path so the
|
|
2880
|
+
file isn't lost. Returns ``success=False`` so callers know the
|
|
2881
|
+
attachment did not land.
|
|
2882
|
+
"""
|
|
2883
|
+
lines = []
|
|
2884
|
+
if caption:
|
|
2885
|
+
lines.append(caption)
|
|
2886
|
+
lines.extend([
|
|
2887
|
+
f"⚠️ No he podido adjuntar **{filename}**.",
|
|
2888
|
+
"Google Chat sólo permite adjuntar archivos cuando el bot tiene "
|
|
2889
|
+
"permiso explícito tuyo (OAuth de usuario). Es un consentimiento "
|
|
2890
|
+
"único que se hace desde este chat.",
|
|
2891
|
+
"**Para activarlo:** envía `/setup-files` y sigue las instrucciones.",
|
|
2892
|
+
f"Mientras tanto el archivo está en el host: `{path}`",
|
|
2893
|
+
])
|
|
2894
|
+
body: Dict[str, Any] = {"text": "\n".join(lines)}
|
|
2895
|
+
if thread_id:
|
|
2896
|
+
body["thread"] = {"name": thread_id}
|
|
2897
|
+
try:
|
|
2898
|
+
await self._create_message(chat_id, body)
|
|
2899
|
+
except Exception:
|
|
2900
|
+
logger.debug(
|
|
2901
|
+
"[GoogleChat] attachment fallback notice send failed",
|
|
2902
|
+
exc_info=True,
|
|
2903
|
+
)
|
|
2904
|
+
return SendResult(
|
|
2905
|
+
success=False,
|
|
2906
|
+
error="google_chat: native attachment requires user OAuth — "
|
|
2907
|
+
"run /setup-files in chat",
|
|
2908
|
+
)
|
|
2909
|
+
|
|
2910
|
+
# ------------------------------------------------------------------
|
|
2911
|
+
# Metadata
|
|
2912
|
+
# ------------------------------------------------------------------
|
|
2913
|
+
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
2914
|
+
"""Return {name, type, chat_id} for a space."""
|
|
2915
|
+
try:
|
|
2916
|
+
info = await asyncio.to_thread(
|
|
2917
|
+
lambda: self._chat_api.spaces()
|
|
2918
|
+
.get(name=chat_id)
|
|
2919
|
+
.execute(http=self._new_authed_http())
|
|
2920
|
+
)
|
|
2921
|
+
except HttpError as exc:
|
|
2922
|
+
logger.debug(
|
|
2923
|
+
"[GoogleChat] get_chat_info failed: %s", _redact_sensitive(str(exc))
|
|
2924
|
+
)
|
|
2925
|
+
return {"name": chat_id, "type": "group", "chat_id": chat_id}
|
|
2926
|
+
space_type = (info.get("spaceType") or info.get("type") or "").upper()
|
|
2927
|
+
display = info.get("displayName") or chat_id
|
|
2928
|
+
return {
|
|
2929
|
+
"name": display,
|
|
2930
|
+
"type": "dm" if space_type in ("DIRECT_MESSAGE", "DM") else "group",
|
|
2931
|
+
"chat_id": chat_id,
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
|
|
2935
|
+
# ---------------------------------------------------------------------------
|
|
2936
|
+
# Plugin entry point
|
|
2937
|
+
# ---------------------------------------------------------------------------
|
|
2938
|
+
|
|
2939
|
+
|
|
2940
|
+
def _validate_config(config: PlatformConfig) -> bool:
|
|
2941
|
+
"""Plugin-side config gate: require both Pub/Sub project and subscription.
|
|
2942
|
+
|
|
2943
|
+
Mirrors the legacy dispatch entry in ``gateway/config.py`` so the
|
|
2944
|
+
registry can decide whether the platform is configured without
|
|
2945
|
+
importing the legacy table.
|
|
2946
|
+
"""
|
|
2947
|
+
extra = getattr(config, "extra", {}) or {}
|
|
2948
|
+
return bool(
|
|
2949
|
+
extra.get("project_id") and extra.get("subscription_name")
|
|
2950
|
+
)
|
|
2951
|
+
|
|
2952
|
+
|
|
2953
|
+
def _check_for_registry() -> bool:
|
|
2954
|
+
"""``check_fn`` for the platform registry pass — stricter than the
|
|
2955
|
+
deps-only ``check_google_chat_requirements``.
|
|
2956
|
+
|
|
2957
|
+
The registry pass at ``gateway/config.py:_apply_env_overrides`` adds
|
|
2958
|
+
the platform to ``cfg.platforms`` whenever ``check_fn`` returns True.
|
|
2959
|
+
For backward compat with the pre-plugin behavior, we ALSO require
|
|
2960
|
+
the minimum Pub/Sub env vars so an unconfigured user doesn't
|
|
2961
|
+
accidentally see ``google_chat`` enabled. This matches the legacy
|
|
2962
|
+
``if gc_project and gc_subscription`` gate.
|
|
2963
|
+
"""
|
|
2964
|
+
if not check_google_chat_requirements():
|
|
2965
|
+
return False
|
|
2966
|
+
project = (
|
|
2967
|
+
os.getenv("GOOGLE_CHAT_PROJECT_ID")
|
|
2968
|
+
or os.getenv("GOOGLE_CLOUD_PROJECT")
|
|
2969
|
+
)
|
|
2970
|
+
subscription = (
|
|
2971
|
+
os.getenv("GOOGLE_CHAT_SUBSCRIPTION_NAME")
|
|
2972
|
+
or os.getenv("GOOGLE_CHAT_SUBSCRIPTION")
|
|
2973
|
+
)
|
|
2974
|
+
return bool(project and subscription)
|
|
2975
|
+
|
|
2976
|
+
|
|
2977
|
+
def _is_connected(config: PlatformConfig) -> bool:
|
|
2978
|
+
"""``GatewayConfig.get_connected_platforms()`` polls this."""
|
|
2979
|
+
return bool(getattr(config, "enabled", False)) and _validate_config(config)
|
|
2980
|
+
|
|
2981
|
+
|
|
2982
|
+
def _env_enablement() -> Optional[Dict[str, Any]]:
|
|
2983
|
+
"""Seed ``PlatformConfig.extra`` from env vars during
|
|
2984
|
+
``_apply_env_overrides``.
|
|
2985
|
+
|
|
2986
|
+
The registry's env-enablement hook is called BEFORE the adapter is
|
|
2987
|
+
constructed, so ``gateway status`` and ``get_connected_platforms()``
|
|
2988
|
+
reflect env-only configuration without instantiating the Pub/Sub client.
|
|
2989
|
+
Returns ``None`` when the required Pub/Sub project/subscription aren't
|
|
2990
|
+
set; the caller then skips auto-enabling the platform.
|
|
2991
|
+
|
|
2992
|
+
The special ``home_channel`` key in the returned dict is handled by the
|
|
2993
|
+
core hook — it becomes a proper ``HomeChannel`` dataclass on the
|
|
2994
|
+
``PlatformConfig`` rather than being merged into ``extra``.
|
|
2995
|
+
"""
|
|
2996
|
+
project = (
|
|
2997
|
+
os.getenv("GOOGLE_CHAT_PROJECT_ID")
|
|
2998
|
+
or os.getenv("GOOGLE_CLOUD_PROJECT")
|
|
2999
|
+
)
|
|
3000
|
+
subscription = (
|
|
3001
|
+
os.getenv("GOOGLE_CHAT_SUBSCRIPTION_NAME")
|
|
3002
|
+
or os.getenv("GOOGLE_CHAT_SUBSCRIPTION")
|
|
3003
|
+
)
|
|
3004
|
+
if not (project and subscription):
|
|
3005
|
+
return None
|
|
3006
|
+
seed: Dict[str, Any] = {
|
|
3007
|
+
"project_id": project,
|
|
3008
|
+
"subscription_name": subscription,
|
|
3009
|
+
}
|
|
3010
|
+
sa_json = (
|
|
3011
|
+
os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON")
|
|
3012
|
+
or os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
|
3013
|
+
)
|
|
3014
|
+
if sa_json:
|
|
3015
|
+
seed["service_account_json"] = sa_json
|
|
3016
|
+
home = os.getenv("GOOGLE_CHAT_HOME_CHANNEL")
|
|
3017
|
+
if home:
|
|
3018
|
+
seed["home_channel"] = {
|
|
3019
|
+
"chat_id": home,
|
|
3020
|
+
"name": os.getenv("GOOGLE_CHAT_HOME_CHANNEL_NAME", "Home"),
|
|
3021
|
+
}
|
|
3022
|
+
return seed
|
|
3023
|
+
|
|
3024
|
+
|
|
3025
|
+
def interactive_setup() -> None:
|
|
3026
|
+
"""Walk the user through Google Chat configuration via ``hermes setup``.
|
|
3027
|
+
|
|
3028
|
+
The setup wizard at ``hermes_cli/gateway.py`` calls this for plugin
|
|
3029
|
+
platforms instead of using the in-tree ``_PLATFORMS`` data block. The
|
|
3030
|
+
flow mirrors the in-tree built-ins: print the GCP setup instructions,
|
|
3031
|
+
prompt for env vars, persist them to ``~/.hermes/.env`` so the next
|
|
3032
|
+
gateway restart picks them up.
|
|
3033
|
+
"""
|
|
3034
|
+
from hermes_cli.cli_output import (
|
|
3035
|
+
print_info,
|
|
3036
|
+
print_success,
|
|
3037
|
+
print_warning,
|
|
3038
|
+
prompt,
|
|
3039
|
+
prompt_yes_no,
|
|
3040
|
+
)
|
|
3041
|
+
from hermes_cli.config import get_env_value, save_env_value
|
|
3042
|
+
|
|
3043
|
+
existing_sub = get_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME")
|
|
3044
|
+
if existing_sub:
|
|
3045
|
+
print_info(f"Google Chat: already configured (subscription: {existing_sub})")
|
|
3046
|
+
if not prompt_yes_no("Reconfigure Google Chat?", False):
|
|
3047
|
+
return
|
|
3048
|
+
|
|
3049
|
+
print_info("Google Chat needs a GCP project, a Pub/Sub topic + subscription,")
|
|
3050
|
+
print_info("and a Service Account with Pub/Sub Subscriber on the subscription.")
|
|
3051
|
+
print_info("Walkthrough:")
|
|
3052
|
+
print_info(" 1. Create or select a GCP project; enable Google Chat API + Cloud Pub/Sub API.")
|
|
3053
|
+
print_info(" 2. Create a Service Account (no project-level IAM role needed).")
|
|
3054
|
+
print_info(" 3. Create a Pub/Sub topic (e.g. hermes-chat-events) and a Pull subscription.")
|
|
3055
|
+
print_info(" 4. On the TOPIC: add chat-api-push@system.gserviceaccount.com as Pub/Sub Publisher.")
|
|
3056
|
+
print_info(" 5. On the SUBSCRIPTION: grant your Service Account Pub/Sub Subscriber.")
|
|
3057
|
+
print_info(" 6. Download the Service Account JSON key.")
|
|
3058
|
+
print_info(" 7. Google Chat API console → Configuration: connection = Cloud Pub/Sub,")
|
|
3059
|
+
print_info(" point at the topic, enable 1:1 + group, restrict visibility.")
|
|
3060
|
+
print_info(" 8. Install the bot in a space (fires ADDED_TO_SPACE and resolves its user_id).")
|
|
3061
|
+
print_info("")
|
|
3062
|
+
print_info("Full guide: website/docs/user-guide/messaging/google_chat.md")
|
|
3063
|
+
print_info("")
|
|
3064
|
+
|
|
3065
|
+
project = prompt(
|
|
3066
|
+
"GCP project ID (e.g. my-project)",
|
|
3067
|
+
default=get_env_value("GOOGLE_CHAT_PROJECT_ID") or "",
|
|
3068
|
+
)
|
|
3069
|
+
if not project:
|
|
3070
|
+
print_warning("Project ID is required — skipping Google Chat setup")
|
|
3071
|
+
return
|
|
3072
|
+
save_env_value("GOOGLE_CHAT_PROJECT_ID", project.strip())
|
|
3073
|
+
|
|
3074
|
+
subscription = prompt(
|
|
3075
|
+
"Pub/Sub subscription (projects/<proj>/subscriptions/<sub>)",
|
|
3076
|
+
default=get_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME") or "",
|
|
3077
|
+
)
|
|
3078
|
+
if not subscription:
|
|
3079
|
+
print_warning("Subscription is required — skipping Google Chat setup")
|
|
3080
|
+
return
|
|
3081
|
+
save_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME", subscription.strip())
|
|
3082
|
+
|
|
3083
|
+
sa_path = prompt(
|
|
3084
|
+
"Path to Service Account JSON (or inline JSON)",
|
|
3085
|
+
default=get_env_value("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") or "",
|
|
3086
|
+
password=True,
|
|
3087
|
+
)
|
|
3088
|
+
if sa_path:
|
|
3089
|
+
save_env_value("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", sa_path.strip())
|
|
3090
|
+
|
|
3091
|
+
if prompt_yes_no("Restrict access to specific users? (recommended)", True):
|
|
3092
|
+
allowed = prompt(
|
|
3093
|
+
"Allowed user emails (comma-separated)",
|
|
3094
|
+
default=get_env_value("GOOGLE_CHAT_ALLOWED_USERS") or "",
|
|
3095
|
+
)
|
|
3096
|
+
if allowed:
|
|
3097
|
+
save_env_value("GOOGLE_CHAT_ALLOWED_USERS", allowed.replace(" ", ""))
|
|
3098
|
+
print_success("Allowlist configured")
|
|
3099
|
+
else:
|
|
3100
|
+
save_env_value("GOOGLE_CHAT_ALLOWED_USERS", "")
|
|
3101
|
+
else:
|
|
3102
|
+
save_env_value("GOOGLE_CHAT_ALLOW_ALL_USERS", "true")
|
|
3103
|
+
print_warning("⚠️ Open access — anyone who can DM the bot can command it.")
|
|
3104
|
+
|
|
3105
|
+
home = prompt(
|
|
3106
|
+
"Home space for cron/notification delivery (e.g. spaces/AAAA, or empty)",
|
|
3107
|
+
default=get_env_value("GOOGLE_CHAT_HOME_CHANNEL") or "",
|
|
3108
|
+
)
|
|
3109
|
+
if home:
|
|
3110
|
+
save_env_value("GOOGLE_CHAT_HOME_CHANNEL", home.strip())
|
|
3111
|
+
|
|
3112
|
+
print()
|
|
3113
|
+
print_success("Google Chat configuration saved to ~/.hermes/.env")
|
|
3114
|
+
print_info("Restart the gateway: hermes gateway restart")
|
|
3115
|
+
|
|
3116
|
+
|
|
3117
|
+
# Strict resource-name pattern. ``spaces/<id>`` and ``users/<id>`` must
|
|
3118
|
+
# only contain Google Chat's documented character set; anything else
|
|
3119
|
+
# means a tampered chat_id trying to break out of the REST URL path
|
|
3120
|
+
# (path traversal, ``?`` query injection, ``#`` fragment truncation).
|
|
3121
|
+
_GCHAT_CHAT_ID_RE = re.compile(r"^(?:spaces|users)/[A-Za-z0-9_-]+$")
|
|
3122
|
+
|
|
3123
|
+
|
|
3124
|
+
async def _standalone_send(
|
|
3125
|
+
pconfig,
|
|
3126
|
+
chat_id: str,
|
|
3127
|
+
message: str,
|
|
3128
|
+
*,
|
|
3129
|
+
thread_id: Optional[str] = None,
|
|
3130
|
+
media_files: Optional[List[str]] = None,
|
|
3131
|
+
force_document: bool = False,
|
|
3132
|
+
) -> Dict[str, Any]:
|
|
3133
|
+
"""POST a single Google Chat message via the REST API without the SDK.
|
|
3134
|
+
|
|
3135
|
+
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
|
|
3136
|
+
runner is not in this process (e.g. ``hermes cron`` running as a
|
|
3137
|
+
separate process from ``hermes gateway``). Without this hook,
|
|
3138
|
+
``deliver=google_chat`` cron jobs fail with ``No live adapter for
|
|
3139
|
+
platform``.
|
|
3140
|
+
|
|
3141
|
+
Configuration: requires service-account credentials via
|
|
3142
|
+
``GOOGLE_CHAT_SERVICE_ACCOUNT_JSON``, ``GOOGLE_APPLICATION_CREDENTIALS``,
|
|
3143
|
+
or Application Default Credentials, and a space resource name as
|
|
3144
|
+
``chat_id`` (e.g. ``spaces/AAAA-BBBB`` or ``users/<id>``).
|
|
3145
|
+
|
|
3146
|
+
Security: ``chat_id`` is validated against the documented Google Chat
|
|
3147
|
+
resource-name character set before substitution into the REST URL so
|
|
3148
|
+
a tampered value cannot path-traverse or query-inject.
|
|
3149
|
+
|
|
3150
|
+
``media_files`` and ``force_document`` are accepted for signature
|
|
3151
|
+
parity but are not implemented for the standalone path; messages with
|
|
3152
|
+
attachments send as text-only. The live adapter handles attachments.
|
|
3153
|
+
"""
|
|
3154
|
+
if not chat_id:
|
|
3155
|
+
return {"error": "Google Chat standalone send: chat_id (space resource) is required"}
|
|
3156
|
+
if not _GCHAT_CHAT_ID_RE.match(chat_id):
|
|
3157
|
+
return {"error": (
|
|
3158
|
+
f"Google Chat standalone send: chat_id {chat_id!r} must match "
|
|
3159
|
+
f"'spaces/<id>' or 'users/<id>' with only [A-Za-z0-9_-] in the id"
|
|
3160
|
+
)}
|
|
3161
|
+
if thread_id is not None and not re.match(r"^spaces/[A-Za-z0-9_-]+/threads/[A-Za-z0-9_-]+$", thread_id):
|
|
3162
|
+
return {"error": (
|
|
3163
|
+
f"Google Chat standalone send: thread_id {thread_id!r} must match "
|
|
3164
|
+
f"'spaces/<id>/threads/<id>'"
|
|
3165
|
+
)}
|
|
3166
|
+
|
|
3167
|
+
extra = getattr(pconfig, "extra", {}) or {}
|
|
3168
|
+
sa_value = (
|
|
3169
|
+
extra.get("service_account_json")
|
|
3170
|
+
or os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON")
|
|
3171
|
+
or os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
|
3172
|
+
)
|
|
3173
|
+
|
|
3174
|
+
if service_account is None:
|
|
3175
|
+
return {"error": "Google Chat standalone send: google-auth not installed"}
|
|
3176
|
+
|
|
3177
|
+
try:
|
|
3178
|
+
from google.auth.transport.requests import Request as _GoogleAuthRequest
|
|
3179
|
+
except Exception as e:
|
|
3180
|
+
return {"error": f"Google Chat standalone send: google-auth import failed: {e}"}
|
|
3181
|
+
|
|
3182
|
+
try:
|
|
3183
|
+
if sa_value:
|
|
3184
|
+
stripped = sa_value.lstrip()
|
|
3185
|
+
if stripped.startswith("{"):
|
|
3186
|
+
try:
|
|
3187
|
+
info = json.loads(sa_value)
|
|
3188
|
+
except json.JSONDecodeError as exc:
|
|
3189
|
+
return {"error": f"Google Chat standalone send: inline SA JSON is invalid: {exc}"}
|
|
3190
|
+
creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES)
|
|
3191
|
+
else:
|
|
3192
|
+
if not os.path.exists(sa_value):
|
|
3193
|
+
return {"error": f"Google Chat standalone send: SA JSON file not found at {sa_value}"}
|
|
3194
|
+
try:
|
|
3195
|
+
with open(sa_value, "r", encoding="utf-8") as fh:
|
|
3196
|
+
info = json.load(fh)
|
|
3197
|
+
except json.JSONDecodeError as exc:
|
|
3198
|
+
return {"error": f"Google Chat standalone send: SA JSON file is invalid: {exc}"}
|
|
3199
|
+
creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES)
|
|
3200
|
+
else:
|
|
3201
|
+
try:
|
|
3202
|
+
import google.auth as _google_auth
|
|
3203
|
+
except ImportError:
|
|
3204
|
+
return {"error": (
|
|
3205
|
+
"Google Chat standalone send: no SA credentials configured "
|
|
3206
|
+
"and google-auth is not installed for ADC fallback"
|
|
3207
|
+
)}
|
|
3208
|
+
try:
|
|
3209
|
+
creds, _project = _google_auth.default(scopes=_CHAT_SCOPES)
|
|
3210
|
+
except Exception as exc:
|
|
3211
|
+
return {"error": (
|
|
3212
|
+
f"Google Chat standalone send: no SA credentials configured "
|
|
3213
|
+
f"and Application Default Credentials are unavailable: {exc}"
|
|
3214
|
+
)}
|
|
3215
|
+
except asyncio.CancelledError:
|
|
3216
|
+
raise
|
|
3217
|
+
except Exception as e:
|
|
3218
|
+
return {"error": f"Google Chat standalone send: credential load failed: {e}"}
|
|
3219
|
+
|
|
3220
|
+
# Bound the synchronous urllib3-backed token refresh so a hung Google
|
|
3221
|
+
# STS endpoint cannot stall the cron scheduler indefinitely.
|
|
3222
|
+
try:
|
|
3223
|
+
await asyncio.wait_for(
|
|
3224
|
+
asyncio.to_thread(creds.refresh, _GoogleAuthRequest()),
|
|
3225
|
+
timeout=10.0,
|
|
3226
|
+
)
|
|
3227
|
+
except asyncio.TimeoutError:
|
|
3228
|
+
return {"error": "Google Chat standalone send: token refresh timed out"}
|
|
3229
|
+
except asyncio.CancelledError:
|
|
3230
|
+
raise
|
|
3231
|
+
except Exception as e:
|
|
3232
|
+
return {"error": f"Google Chat standalone send: token refresh failed: {e}"}
|
|
3233
|
+
|
|
3234
|
+
token = getattr(creds, "token", None)
|
|
3235
|
+
if not token:
|
|
3236
|
+
return {"error": "Google Chat standalone send: refreshed credentials have no token"}
|
|
3237
|
+
|
|
3238
|
+
body: Dict[str, Any] = {"text": message}
|
|
3239
|
+
if thread_id:
|
|
3240
|
+
body["thread"] = {"name": thread_id}
|
|
3241
|
+
|
|
3242
|
+
url = f"https://chat.googleapis.com/v1/{chat_id}/messages"
|
|
3243
|
+
try:
|
|
3244
|
+
import aiohttp as _aiohttp
|
|
3245
|
+
except ImportError:
|
|
3246
|
+
return {"error": "Google Chat standalone send: aiohttp not installed"}
|
|
3247
|
+
|
|
3248
|
+
try:
|
|
3249
|
+
async with _aiohttp.ClientSession(timeout=_aiohttp.ClientTimeout(total=30.0)) as session:
|
|
3250
|
+
async with session.post(
|
|
3251
|
+
url,
|
|
3252
|
+
json=body,
|
|
3253
|
+
headers={
|
|
3254
|
+
"Authorization": f"Bearer {token}",
|
|
3255
|
+
"Content-Type": "application/json",
|
|
3256
|
+
},
|
|
3257
|
+
) as resp:
|
|
3258
|
+
if resp.status >= 400:
|
|
3259
|
+
text = await resp.text()
|
|
3260
|
+
return {"error": (
|
|
3261
|
+
f"Google Chat standalone send: API returned "
|
|
3262
|
+
f"{resp.status}: {text[:300]}"
|
|
3263
|
+
)}
|
|
3264
|
+
payload = await resp.json()
|
|
3265
|
+
return {
|
|
3266
|
+
"success": True,
|
|
3267
|
+
"message_id": payload.get("name"),
|
|
3268
|
+
}
|
|
3269
|
+
except asyncio.CancelledError:
|
|
3270
|
+
raise
|
|
3271
|
+
except Exception as e:
|
|
3272
|
+
logger.debug("Google Chat standalone send raised", exc_info=True)
|
|
3273
|
+
return {"error": f"Google Chat standalone send failed: {e}"}
|
|
3274
|
+
|
|
3275
|
+
|
|
3276
|
+
def register(ctx) -> None:
|
|
3277
|
+
"""Plugin entry point — called by the Hermes plugin system at startup.
|
|
3278
|
+
|
|
3279
|
+
Registers the Google Chat adapter under the ``google_chat`` name.
|
|
3280
|
+
The gateway's ``_create_adapter`` consults the platform registry
|
|
3281
|
+
BEFORE its built-in if/elif chain, so this registration is what
|
|
3282
|
+
drives adapter creation at runtime.
|
|
3283
|
+
"""
|
|
3284
|
+
ctx.register_platform(
|
|
3285
|
+
name="google_chat",
|
|
3286
|
+
label="Google Chat",
|
|
3287
|
+
adapter_factory=lambda cfg: GoogleChatAdapter(cfg),
|
|
3288
|
+
check_fn=_check_for_registry,
|
|
3289
|
+
validate_config=_validate_config,
|
|
3290
|
+
is_connected=_is_connected,
|
|
3291
|
+
required_env=[
|
|
3292
|
+
"GOOGLE_CHAT_PROJECT_ID",
|
|
3293
|
+
"GOOGLE_CHAT_SUBSCRIPTION_NAME",
|
|
3294
|
+
"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON",
|
|
3295
|
+
],
|
|
3296
|
+
install_hint="pip install 'hermes-agent[google_chat]'",
|
|
3297
|
+
setup_fn=interactive_setup,
|
|
3298
|
+
# Env-driven auto-configuration — the core env-populator hook calls
|
|
3299
|
+
# this during ``_apply_env_overrides`` and seeds
|
|
3300
|
+
# ``PlatformConfig.extra`` + home_channel from env vars. Without this
|
|
3301
|
+
# the adapter would still work on explicit config.yaml entries, but
|
|
3302
|
+
# env-only setup (GOOGLE_CHAT_PROJECT_ID/_SUBSCRIPTION_NAME/...) would
|
|
3303
|
+
# not flow through to ``gateway status`` or ``get_connected_platforms``.
|
|
3304
|
+
env_enablement_fn=_env_enablement,
|
|
3305
|
+
# Cron home-channel delivery support. Lets ``deliver=google_chat``
|
|
3306
|
+
# cron jobs route to the configured home space without editing
|
|
3307
|
+
# cron/scheduler.py's hardcoded sets.
|
|
3308
|
+
cron_deliver_env_var="GOOGLE_CHAT_HOME_CHANNEL",
|
|
3309
|
+
# Out-of-process cron delivery via the Chat REST API. Without this
|
|
3310
|
+
# hook, deliver=google_chat cron jobs fail with "No live adapter"
|
|
3311
|
+
# when cron runs separately from the gateway.
|
|
3312
|
+
standalone_sender_fn=_standalone_send,
|
|
3313
|
+
# Auth env vars for _is_user_authorized() integration.
|
|
3314
|
+
allowed_users_env="GOOGLE_CHAT_ALLOWED_USERS",
|
|
3315
|
+
allow_all_env="GOOGLE_CHAT_ALLOW_ALL_USERS",
|
|
3316
|
+
# Chat caps text messages at 4096 chars; we leave margin to fit
|
|
3317
|
+
# the "Hermes is thinking..." marker patches and edit overhead.
|
|
3318
|
+
max_message_length=4000,
|
|
3319
|
+
emoji="💬",
|
|
3320
|
+
allow_update_command=True,
|
|
3321
|
+
platform_hint=(
|
|
3322
|
+
"You are on Google Chat. Limited markdown subset is rendered: "
|
|
3323
|
+
"*bold*, _italic_, ~strike~, `code`. No headings or lists. "
|
|
3324
|
+
"Message size limit: 4000 characters; longer responses are split "
|
|
3325
|
+
"across multiple messages. You are in a space (DM or group). "
|
|
3326
|
+
"Images render inline; audio, video, and document attachments "
|
|
3327
|
+
"render as download cards (no native voice/video UI). To send "
|
|
3328
|
+
"files, include MEDIA:/absolute/path/to/file in your response. "
|
|
3329
|
+
"Native file attachments require the user to run /setup-files "
|
|
3330
|
+
"once in their own DM — until they do, file requests fall back "
|
|
3331
|
+
"to a text notice with the host path. Do NOT generate interactive "
|
|
3332
|
+
"Card v2 buttons — Google Chat interactivity is not yet supported "
|
|
3333
|
+
"by this gateway; ask for typed confirmations instead. While you "
|
|
3334
|
+
"are generating a response, a 'Hermes is thinking…' marker message "
|
|
3335
|
+
"appears in the space and is deleted once your response is ready. "
|
|
3336
|
+
"You do NOT have access to Google Chat-specific APIs — you cannot "
|
|
3337
|
+
"search space history, list space members, or manage spaces. Do "
|
|
3338
|
+
"not promise to perform these actions; explain that you can only "
|
|
3339
|
+
"read messages sent directly to you and respond in the same "
|
|
3340
|
+
"space/thread."
|
|
3341
|
+
),
|
|
3342
|
+
)
|
|
3343
|
+
|