comodor 0.19.2__tar.gz → 0.20.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {comodor-0.19.2 → comodor-0.20.0}/PKG-INFO +3 -2
- {comodor-0.19.2 → comodor-0.20.0}/README.md +2 -1
- {comodor-0.19.2 → comodor-0.20.0}/docs/README.md +1 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/_version.py +2 -2
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/browser/cdp.py +21 -123
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/channels/__init__.py +17 -1
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/cli.py +6 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/config.py +56 -5
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/doctor.py +7 -2
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/help.py +28 -0
- comodor-0.20.0/src/comodor/net/ws.py +210 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/setup.py +32 -1
- comodor-0.20.0/src/comodor/slack/__init__.py +7 -0
- comodor-0.20.0/src/comodor/slack/api.py +237 -0
- comodor-0.20.0/src/comodor/slack/blocks.py +221 -0
- comodor-0.20.0/src/comodor/slack/bot.py +742 -0
- comodor-0.20.0/src/comodor/slack/commands.py +521 -0
- comodor-0.20.0/src/comodor/slack/service.py +33 -0
- comodor-0.20.0/src/comodor/slack/socket.py +202 -0
- comodor-0.20.0/src/comodor/slack/unit.py +24 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/server.py +26 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/session.py +266 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/ui.js +216 -36
- comodor-0.20.0/tests/test_slack.py +488 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_web.py +107 -0
- {comodor-0.19.2 → comodor-0.20.0}/.gitignore +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/LICENSE +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/pyproject.toml +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/__main__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/acp/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/acp/agent.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/acp/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/acp/jsonrpc.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/context.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/loop.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/prompts.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/spawn.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/tokens.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/browser/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/browser/launch.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/browser/page.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/catalogue.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/channels/daemon.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/channels/unit.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/guard.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/keys.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/overlay.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/png.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/screen.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/win32.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/events.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/associations.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/bm25.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/hotindex.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/memory.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/progress.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/reflect.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/rules.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/signals.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/store.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/writer.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/catalogue.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/download.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/models.json +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/provider.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/runtime.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/store.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/catalogue.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/http.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/manager.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/protocol.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/migrate.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/net/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/net/http.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/net/sse.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/paths.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/anthropic.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/base.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/caching.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/discover.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/fake.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/gateway.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/models.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/oauth.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/openai_compat.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/registry.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/questions.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/safety/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/safety/checkpoints.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/safety/permissions.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/safety/redact.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/session/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/session/search.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/session/store.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/catalogue.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/examples.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/loader.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/propose.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/registry.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/api.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/bot.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/keyboard.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/service.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/unit.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/ask.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/base.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/browse.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/browser.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/computer.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/delegate.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/fs.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/history.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/mcp.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/overflow.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/registry.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/search.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/shell.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/skills.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/todo.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/web.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/app.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/banner.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/bidi.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/chooser.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/clipboard.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/console.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/input/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/input/keys.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/input/reader.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/layout.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/markdown.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/screen.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/theme.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/buttons.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/chat.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/history.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/overlay.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/panel.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/progress.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/prompt.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/questions.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/statusbar.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/toast.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/welcome.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/uninstall.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/update.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/index.html +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/ui.css +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/vazirmatn-OFL.txt +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/vazirmatn.woff2 +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/__init__.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/api.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/bot.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/commands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/guide.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/menu.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/service.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/tunnel.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/unit.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/webhook.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/src/comodor/workspace.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/conftest.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/support/fake_mcp_server.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_acp.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_agent_loop.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_app.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_associations.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_banner.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_bidi.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_browser.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_caching.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_channel_service.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_chooser.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_computer_tool.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_config_save.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_copy_and_bands.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_delegate.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_desktop.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_discover.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_doctor.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_help.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_history.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_import_command.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_input.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_layout.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_learning.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_local.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_markdown.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_mcp.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_mcp_http.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_migrate.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_oauth.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_overflow.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_overlay.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_performance.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_prefix_stability.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_progress.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_propose.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_providers.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_questions.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_real_browser.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_real_desktop.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_real_web_ui.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_reflex.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_run_loop.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_setup.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_setup_import.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_skill_budget.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_skill_catalogue.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_skills.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_switching_model.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_telegram.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_tools.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_uninstall.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_update.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_welcome.py +0 -0
- {comodor-0.19.2 → comodor-0.20.0}/tests/test_whatsapp.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: comodor
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.20.0
|
|
4
4
|
Summary: Comodor — a self-improving terminal coding agent with a Rich TUI
|
|
5
5
|
Project-URL: Homepage, https://comodor.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/ifekri/Comodor
|
|
@@ -75,7 +75,7 @@ falling or not. A number, not a claim.
|
|
|
75
75
|
| **Never surprises you** | Reading is silent. Writing shows a diff. Commands ask. Every change is checkpointed and `/undo` puts it back. [→](docs/safety.md) |
|
|
76
76
|
| **Drives a real browser** | One that runs JavaScript, keeps cookies and can log in — not a page fetcher. [→](docs/browser.md) |
|
|
77
77
|
| **Uses your screen** | Mouse and keyboard in any application, with a halo showing where it is about to click before it clicks. [→](docs/computer.md) |
|
|
78
|
-
| **Runs on your phone** | Telegram or WhatsApp
|
|
78
|
+
| **Runs on your phone** | Telegram, Slack or WhatsApp — the whole interface as buttons, running in the background whether or not a terminal is open. Read-only until you say otherwise. [→](docs/telegram.md) · [→](docs/slack.md) · [→](docs/whatsapp.md) |
|
|
79
79
|
| **Runs a model locally** | Pick one, watch it download, use it with the network unplugged. No key, no account. [→](docs/local-models.md) |
|
|
80
80
|
| **Lives in your editor** | Speaks ACP, so VS Code, JetBrains and Zed drive it from their own panels. [→](docs/acp.md) |
|
|
81
81
|
| **Follows your procedures** | Write a skill once; it loads when the work matches. 147 ready to install. [→](docs/skills.md) |
|
|
@@ -174,6 +174,7 @@ Esc stop it F3 cycle mode
|
|
|
174
174
|
| [What it can do](docs/tools.md) | The 13 tools, and when it uses each |
|
|
175
175
|
| [Safety and permissions](docs/safety.md) | What it asks, and what it never does |
|
|
176
176
|
| [From your phone](docs/telegram.md) | The Telegram bot, and who it answers |
|
|
177
|
+
| [From Slack](docs/slack.md) | Socket Mode — five minutes, and no public address |
|
|
177
178
|
| [From WhatsApp](docs/whatsapp.md) | The Cloud API — longer to set up; Telegram does the same thing |
|
|
178
179
|
| [Models on your machine](docs/local-models.md) | Downloading one, running it offline |
|
|
179
180
|
| [Configuration](docs/configuration.md) | Every setting, and what wins |
|
|
@@ -48,7 +48,7 @@ falling or not. A number, not a claim.
|
|
|
48
48
|
| **Never surprises you** | Reading is silent. Writing shows a diff. Commands ask. Every change is checkpointed and `/undo` puts it back. [→](docs/safety.md) |
|
|
49
49
|
| **Drives a real browser** | One that runs JavaScript, keeps cookies and can log in — not a page fetcher. [→](docs/browser.md) |
|
|
50
50
|
| **Uses your screen** | Mouse and keyboard in any application, with a halo showing where it is about to click before it clicks. [→](docs/computer.md) |
|
|
51
|
-
| **Runs on your phone** | Telegram or WhatsApp
|
|
51
|
+
| **Runs on your phone** | Telegram, Slack or WhatsApp — the whole interface as buttons, running in the background whether or not a terminal is open. Read-only until you say otherwise. [→](docs/telegram.md) · [→](docs/slack.md) · [→](docs/whatsapp.md) |
|
|
52
52
|
| **Runs a model locally** | Pick one, watch it download, use it with the network unplugged. No key, no account. [→](docs/local-models.md) |
|
|
53
53
|
| **Lives in your editor** | Speaks ACP, so VS Code, JetBrains and Zed drive it from their own panels. [→](docs/acp.md) |
|
|
54
54
|
| **Follows your procedures** | Write a skill once; it loads when the work matches. 147 ready to install. [→](docs/skills.md) |
|
|
@@ -147,6 +147,7 @@ Esc stop it F3 cycle mode
|
|
|
147
147
|
| [What it can do](docs/tools.md) | The 13 tools, and when it uses each |
|
|
148
148
|
| [Safety and permissions](docs/safety.md) | What it asks, and what it never does |
|
|
149
149
|
| [From your phone](docs/telegram.md) | The Telegram bot, and who it answers |
|
|
150
|
+
| [From Slack](docs/slack.md) | Socket Mode — five minutes, and no public address |
|
|
150
151
|
| [From WhatsApp](docs/whatsapp.md) | The Cloud API — longer to set up; Telegram does the same thing |
|
|
151
152
|
| [Models on your machine](docs/local-models.md) | Downloading one, running it offline |
|
|
152
153
|
| [Configuration](docs/configuration.md) | Every setting, and what wins |
|
|
@@ -42,6 +42,7 @@ ends with the agent doing something useful.
|
|
|
42
42
|
| | |
|
|
43
43
|
|---|---|
|
|
44
44
|
| [From your phone](telegram.md) | The Telegram bot: pairing, the buttons, and who it answers |
|
|
45
|
+
| [From Slack](slack.md) | Socket Mode — five minutes, no public address, and it answers in threads |
|
|
45
46
|
| [From WhatsApp](whatsapp.md) | The Cloud API — about twenty minutes and technical. Telegram does the same in one |
|
|
46
47
|
| [Models on your machine](local-models.md) | Downloading one, running it offline, adding to the list |
|
|
47
48
|
| [Questions](questions.md) | The form it puts up when a request reads two ways |
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.20.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 20, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -24,17 +24,16 @@ Two things about the protocol that are easy to get wrong:
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
-
import base64
|
|
28
27
|
import json
|
|
29
|
-
import os
|
|
30
|
-
import socket
|
|
31
|
-
import struct
|
|
32
28
|
import threading
|
|
33
29
|
import time
|
|
34
30
|
import urllib.error
|
|
35
31
|
import urllib.request
|
|
36
32
|
from typing import Any, Callable
|
|
37
33
|
|
|
34
|
+
from ..net.ws import WebSocket as _WebSocket
|
|
35
|
+
from ..net.ws import WebSocketError
|
|
36
|
+
|
|
38
37
|
DEFAULT_TIMEOUT = 30.0
|
|
39
38
|
#: Long enough for a slow page on a slow machine, short enough that a hung
|
|
40
39
|
#: renderer does not hold the turn until the agent's own deadline.
|
|
@@ -50,135 +49,34 @@ class BrowserError(RuntimeError):
|
|
|
50
49
|
# --------------------------------------------------------------------------- #
|
|
51
50
|
|
|
52
51
|
|
|
53
|
-
class WebSocket:
|
|
54
|
-
"""
|
|
52
|
+
class WebSocket(_WebSocket):
|
|
53
|
+
"""The general client, with the browser's vocabulary on its failures.
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
The frame handling moved to `net.ws` when Slack's Socket Mode became a
|
|
56
|
+
second caller for it. This keeps the errors saying "the browser", because
|
|
57
|
+
everything above catches `BrowserError` and a person reading a traceback
|
|
58
|
+
about a websocket is a person who has to work out which websocket.
|
|
59
59
|
"""
|
|
60
60
|
|
|
61
61
|
def __init__(self, url: str, timeout: float = DEFAULT_TIMEOUT) -> None:
|
|
62
|
-
_, _, rest = url.partition("://")
|
|
63
|
-
hostport, _, path = rest.partition("/")
|
|
64
|
-
host, _, port = hostport.partition(":")
|
|
65
62
|
try:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
self._socket.settimeout(timeout)
|
|
71
|
-
|
|
72
|
-
key = base64.b64encode(os.urandom(16)).decode()
|
|
73
|
-
self._socket.sendall(
|
|
74
|
-
f"GET /{path} HTTP/1.1\r\n"
|
|
75
|
-
f"Host: {hostport}\r\n"
|
|
76
|
-
f"Upgrade: websocket\r\n"
|
|
77
|
-
f"Connection: Upgrade\r\n"
|
|
78
|
-
f"Sec-WebSocket-Key: {key}\r\n"
|
|
79
|
-
f"Sec-WebSocket-Version: 13\r\n\r\n".encode())
|
|
80
|
-
|
|
81
|
-
header = b""
|
|
82
|
-
while b"\r\n\r\n" not in header:
|
|
83
|
-
chunk = self._socket.recv(1)
|
|
84
|
-
if not chunk:
|
|
85
|
-
raise BrowserError("the browser closed the connection during the handshake")
|
|
86
|
-
header += chunk
|
|
87
|
-
if len(header) > 8192:
|
|
88
|
-
raise BrowserError("the browser sent a handshake we cannot read")
|
|
89
|
-
if b" 101" not in header.split(b"\r\n")[0]:
|
|
90
|
-
raise BrowserError(f"the browser refused the upgrade: "
|
|
91
|
-
f"{header.split(chr(13).encode())[0][:80]!r}")
|
|
92
|
-
self._buffer = b""
|
|
93
|
-
self._send_lock = threading.Lock()
|
|
94
|
-
|
|
95
|
-
# -- frames ------------------------------------------------------------ #
|
|
63
|
+
super().__init__(url, timeout=timeout)
|
|
64
|
+
except WebSocketError as error:
|
|
65
|
+
raise BrowserError(
|
|
66
|
+
f"could not connect to the browser: {error}") from error
|
|
96
67
|
|
|
97
68
|
def send(self, text: str) -> None:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
frame.append(0x80 | length)
|
|
103
|
-
elif length < 65536:
|
|
104
|
-
frame.append(0x80 | 126)
|
|
105
|
-
frame += struct.pack(">H", length)
|
|
106
|
-
else:
|
|
107
|
-
frame.append(0x80 | 127)
|
|
108
|
-
frame += struct.pack(">Q", length)
|
|
109
|
-
# A client must mask; a server must not. Chrome enforces both.
|
|
110
|
-
mask = os.urandom(4)
|
|
111
|
-
frame += mask
|
|
112
|
-
frame += bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
|
|
113
|
-
with self._send_lock:
|
|
114
|
-
try:
|
|
115
|
-
self._socket.sendall(bytes(frame))
|
|
116
|
-
except OSError as error:
|
|
117
|
-
raise BrowserError(f"the browser went away: {error}") from error
|
|
69
|
+
try:
|
|
70
|
+
super().send(text)
|
|
71
|
+
except WebSocketError as error:
|
|
72
|
+
raise BrowserError(f"the browser went away: {error}") from error
|
|
118
73
|
|
|
119
74
|
def receive(self) -> str:
|
|
120
|
-
"""One complete message, reassembled if it arrived in fragments."""
|
|
121
|
-
parts: list[bytes] = []
|
|
122
|
-
while True:
|
|
123
|
-
first, second = self._exactly(2)
|
|
124
|
-
final = bool(first & 0x80)
|
|
125
|
-
opcode = first & 0x0F
|
|
126
|
-
length = second & 0x7F
|
|
127
|
-
if length == 126:
|
|
128
|
-
length = struct.unpack(">H", self._exactly(2))[0]
|
|
129
|
-
elif length == 127:
|
|
130
|
-
length = struct.unpack(">Q", self._exactly(8))[0]
|
|
131
|
-
mask = self._exactly(4) if second & 0x80 else b""
|
|
132
|
-
payload = self._exactly(length)
|
|
133
|
-
if mask:
|
|
134
|
-
payload = bytes(byte ^ mask[index % 4]
|
|
135
|
-
for index, byte in enumerate(payload))
|
|
136
|
-
|
|
137
|
-
if opcode == 0x8: # close
|
|
138
|
-
raise BrowserError("the browser closed the connection")
|
|
139
|
-
if opcode == 0x9: # ping -> pong
|
|
140
|
-
self._pong(payload)
|
|
141
|
-
continue
|
|
142
|
-
if opcode == 0xA: # pong
|
|
143
|
-
continue
|
|
144
|
-
|
|
145
|
-
parts.append(payload)
|
|
146
|
-
if final:
|
|
147
|
-
return b"".join(parts).decode("utf-8", "replace")
|
|
148
|
-
|
|
149
|
-
def _pong(self, payload: bytes) -> None:
|
|
150
|
-
frame = bytearray([0x8A, 0x80 | len(payload)])
|
|
151
|
-
mask = os.urandom(4)
|
|
152
|
-
frame += mask
|
|
153
|
-
frame += bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
|
|
154
|
-
with self._send_lock:
|
|
155
|
-
try:
|
|
156
|
-
self._socket.sendall(bytes(frame))
|
|
157
|
-
except OSError:
|
|
158
|
-
pass
|
|
159
|
-
|
|
160
|
-
def _exactly(self, count: int) -> bytes:
|
|
161
|
-
while len(self._buffer) < count:
|
|
162
|
-
try:
|
|
163
|
-
chunk = self._socket.recv(65536)
|
|
164
|
-
except socket.timeout as error:
|
|
165
|
-
raise BrowserError("the browser stopped answering") from error
|
|
166
|
-
except OSError as error:
|
|
167
|
-
raise BrowserError(f"the browser went away: {error}") from error
|
|
168
|
-
if not chunk:
|
|
169
|
-
raise BrowserError("the browser closed the connection")
|
|
170
|
-
self._buffer += chunk
|
|
171
|
-
taken, self._buffer = self._buffer[:count], self._buffer[count:]
|
|
172
|
-
return taken
|
|
173
|
-
|
|
174
|
-
def settimeout(self, timeout: float) -> None:
|
|
175
|
-
self._socket.settimeout(timeout)
|
|
176
|
-
|
|
177
|
-
def close(self) -> None:
|
|
178
75
|
try:
|
|
179
|
-
|
|
180
|
-
except
|
|
181
|
-
|
|
76
|
+
return super().receive()
|
|
77
|
+
except WebSocketError as error:
|
|
78
|
+
raise BrowserError(
|
|
79
|
+
f"the browser closed the connection: {error}") from error
|
|
182
80
|
|
|
183
81
|
|
|
184
82
|
# --------------------------------------------------------------------------- #
|
|
@@ -65,9 +65,25 @@ def _whatsapp_ready(settings: Any) -> tuple[bool, str]:
|
|
|
65
65
|
return True, ""
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
def _slack_ready(settings: Any) -> tuple[bool, str]:
|
|
69
|
+
if not settings.bot_token:
|
|
70
|
+
return False, ("Not connected. `comodor slack connect` sets up the "
|
|
71
|
+
"workspace and the two tokens.")
|
|
72
|
+
if not settings.app_token:
|
|
73
|
+
return False, ("No app-level token, so Socket Mode cannot open — "
|
|
74
|
+
"`comodor slack connect --app-token xapp-…`")
|
|
75
|
+
if not settings.allowed:
|
|
76
|
+
return False, ("Nobody is paired, so it would answer nobody. "
|
|
77
|
+
"`comodor slack pair` first.")
|
|
78
|
+
return True, ""
|
|
79
|
+
|
|
80
|
+
|
|
68
81
|
TELEGRAM = Channel(name="telegram", label="Telegram", section="telegram",
|
|
69
82
|
ready=_telegram_ready)
|
|
70
83
|
WHATSAPP = Channel(name="whatsapp", label="WhatsApp", section="whatsapp",
|
|
71
84
|
ready=_whatsapp_ready)
|
|
72
85
|
|
|
73
|
-
|
|
86
|
+
SLACK = Channel(name="slack", label="Slack", section="slack",
|
|
87
|
+
ready=_slack_ready)
|
|
88
|
+
|
|
89
|
+
CHANNELS = (TELEGRAM, WHATSAPP, SLACK)
|
|
@@ -104,6 +104,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
104
104
|
from .local.commands import register as register_local
|
|
105
105
|
from .mcp.commands import register as register_mcp
|
|
106
106
|
from .skills.commands import register as register_skills
|
|
107
|
+
from .slack.commands import register as register_slack
|
|
107
108
|
from .telegram.commands import register as register_telegram
|
|
108
109
|
from .web.commands import register as register_web
|
|
109
110
|
from .whatsapp.commands import register as register_whatsapp
|
|
@@ -112,6 +113,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
112
113
|
register_local(sub)
|
|
113
114
|
register_telegram(sub)
|
|
114
115
|
register_whatsapp(sub)
|
|
116
|
+
register_slack(sub)
|
|
115
117
|
register_skills(sub)
|
|
116
118
|
register_web(sub)
|
|
117
119
|
register_acp(sub)
|
|
@@ -828,6 +830,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
828
830
|
from .whatsapp.commands import run as run_whatsapp
|
|
829
831
|
|
|
830
832
|
return run_whatsapp(config, args)
|
|
833
|
+
if args.command == "slack":
|
|
834
|
+
from .slack.commands import run as run_slack
|
|
835
|
+
|
|
836
|
+
return run_slack(config, args)
|
|
831
837
|
if args.command == "skills":
|
|
832
838
|
from .skills.commands import run as run_skills
|
|
833
839
|
|
|
@@ -460,6 +460,54 @@ def _digits(value: Any) -> str:
|
|
|
460
460
|
return kept[2:] if kept.startswith("00") else kept
|
|
461
461
|
|
|
462
462
|
|
|
463
|
+
@dataclass
|
|
464
|
+
class SlackConfig:
|
|
465
|
+
"""Reaching the agent from a Slack workspace.
|
|
466
|
+
|
|
467
|
+
Two tokens, because Slack has two kinds. The *bot* token does everything
|
|
468
|
+
the bot does in the workspace; the *app-level* token does one thing —
|
|
469
|
+
open the websocket Socket Mode runs on — and that is what keeps this
|
|
470
|
+
channel as easy to set up as Telegram. Slack's other delivery method posts
|
|
471
|
+
to a URL and would need a public HTTPS address, a certificate and a tunnel,
|
|
472
|
+
which is the whole of what makes WhatsApp hard.
|
|
473
|
+
|
|
474
|
+
A workspace can have hundreds of people in it, and this is an agent that
|
|
475
|
+
reads and writes somebody's files. `allowed` holds the Slack user ids it
|
|
476
|
+
answers; everybody else is ignored.
|
|
477
|
+
"""
|
|
478
|
+
|
|
479
|
+
enabled: bool = False
|
|
480
|
+
#: `xoxb-…`, from OAuth & Permissions after the app is installed.
|
|
481
|
+
bot_token: str = ""
|
|
482
|
+
#: `xapp-…`, from Basic Information → App-Level Tokens, scope
|
|
483
|
+
#: `connections:write`. Only ever used to open the socket.
|
|
484
|
+
app_token: str = ""
|
|
485
|
+
#: Slack user ids — `U…` or `W…`. Not display names: a display name can be
|
|
486
|
+
#: changed by the person holding it, and an id cannot.
|
|
487
|
+
allowed: list[str] = field(default_factory=list)
|
|
488
|
+
#: Whether a turn started from Slack may edit files and run commands.
|
|
489
|
+
allow_writes: bool = False
|
|
490
|
+
#: Seconds a pairing code stays valid.
|
|
491
|
+
pair_window: int = 300
|
|
492
|
+
#: The workspace it was connected to, remembered so `status` can name it
|
|
493
|
+
#: without a round trip.
|
|
494
|
+
team: str = ""
|
|
495
|
+
|
|
496
|
+
def to_json(self) -> dict[str, Any]:
|
|
497
|
+
return {
|
|
498
|
+
"enabled": self.enabled,
|
|
499
|
+
"bot_token": self.bot_token,
|
|
500
|
+
"app_token": self.app_token,
|
|
501
|
+
"allowed": list(self.allowed),
|
|
502
|
+
"allow_writes": self.allow_writes,
|
|
503
|
+
"pair_window": self.pair_window,
|
|
504
|
+
"team": self.team,
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
def may(self, user_id: str) -> bool:
|
|
508
|
+
return bool(user_id) and str(user_id) in {str(x) for x in self.allowed}
|
|
509
|
+
|
|
510
|
+
|
|
463
511
|
@dataclass
|
|
464
512
|
class MCPConfig:
|
|
465
513
|
"""Servers, and the master switch over all of them."""
|
|
@@ -507,7 +555,7 @@ DEFAULT_DENY: tuple[str, ...] = (
|
|
|
507
555
|
#: for its whole existence: `headless: false` in a config file did nothing and
|
|
508
556
|
#: said nothing.
|
|
509
557
|
SECTIONS = ("ui", "agent", "gateway", "learning", "skills", "safety",
|
|
510
|
-
"browser", "computer", "telegram", "whatsapp")
|
|
558
|
+
"browser", "computer", "telegram", "whatsapp", "slack")
|
|
511
559
|
|
|
512
560
|
|
|
513
561
|
@dataclass
|
|
@@ -523,6 +571,7 @@ class Config:
|
|
|
523
571
|
computer: ComputerConfig = field(default_factory=ComputerConfig)
|
|
524
572
|
telegram: TelegramConfig = field(default_factory=TelegramConfig)
|
|
525
573
|
whatsapp: WhatsAppConfig = field(default_factory=WhatsAppConfig)
|
|
574
|
+
slack: SlackConfig = field(default_factory=SlackConfig)
|
|
526
575
|
mcp: MCPConfig = field(default_factory=MCPConfig)
|
|
527
576
|
safety: SafetyConfig = field(default_factory=SafetyConfig)
|
|
528
577
|
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
|
@@ -816,14 +865,16 @@ PROJECT_SETTABLE: dict[str, frozenset[str] | None] = {
|
|
|
816
865
|
# uses is useful; a project starting a process is not its decision. The
|
|
817
866
|
# master switch is deliberately absent.
|
|
818
867
|
"mcp": frozenset({"servers"}),
|
|
819
|
-
# `browser`, `computer`, `telegram` and `
|
|
868
|
+
# `browser`, `computer`, `telegram`, `whatsapp` and `slack` are
|
|
869
|
+
# deliberately absent,
|
|
820
870
|
# and this is the note for whoever is tempted to add them. A project that
|
|
821
871
|
# could set `browser.executable` names a binary for the agent to launch.
|
|
822
872
|
# One that could set `computer.enabled` asks the machine it was just cloned
|
|
823
873
|
# onto for its mouse and keyboard. One that could set `telegram.allowed` or
|
|
824
|
-
# `whatsapp.allowed` adds its author to the list of
|
|
825
|
-
# that machine from a phone — and unlike the other
|
|
826
|
-
# would ever show it happening. None of the
|
|
874
|
+
# `whatsapp.allowed` or `slack.allowed` adds its author to the list of
|
|
875
|
+
# people who may drive that machine from a phone — and unlike the other
|
|
876
|
+
# two, nothing on screen would ever show it happening. None of the five
|
|
877
|
+
# belongs to a repository.
|
|
827
878
|
}
|
|
828
879
|
|
|
829
880
|
|
|
@@ -104,7 +104,7 @@ def run_checks(config: Config, online: bool = True) -> Report:
|
|
|
104
104
|
_check_saved_provider, _check_model, _check_spend_limit,
|
|
105
105
|
_check_brain,
|
|
106
106
|
_check_search_index, _check_skills, _check_leftovers, _check_mcp,
|
|
107
|
-
_check_telegram, _check_whatsapp]
|
|
107
|
+
_check_telegram, _check_whatsapp, _check_slack]
|
|
108
108
|
if online:
|
|
109
109
|
checks.append(_check_version)
|
|
110
110
|
|
|
@@ -480,6 +480,10 @@ def _check_whatsapp(config: Config) -> Finding | None:
|
|
|
480
480
|
return _check_phone(config, "whatsapp")
|
|
481
481
|
|
|
482
482
|
|
|
483
|
+
def _check_slack(config: Config) -> Finding | None:
|
|
484
|
+
return _check_phone(config, "slack")
|
|
485
|
+
|
|
486
|
+
|
|
483
487
|
def _check_phone(config: Config, name: str) -> Finding | None:
|
|
484
488
|
"""A bot that is set up and answering nobody, or not running at all.
|
|
485
489
|
|
|
@@ -493,7 +497,8 @@ def _check_phone(config: Config, name: str) -> Finding | None:
|
|
|
493
497
|
if channel is None:
|
|
494
498
|
return None
|
|
495
499
|
settings = channel.settings(config)
|
|
496
|
-
if not getattr(settings, "token", "")
|
|
500
|
+
if not (getattr(settings, "token", "")
|
|
501
|
+
or getattr(settings, "bot_token", "")):
|
|
497
502
|
return None
|
|
498
503
|
|
|
499
504
|
ok, why = channel.can_run(config)
|
|
@@ -55,6 +55,7 @@ SECTIONS: list[tuple[str, list[tuple[str, str]]]] = [
|
|
|
55
55
|
("Reach further", [
|
|
56
56
|
("comodor web", "use it from a browser, here or on a server"),
|
|
57
57
|
("comodor telegram start", "drive it from your phone, all buttons"),
|
|
58
|
+
("comodor slack start", "the same, in a Slack workspace"),
|
|
58
59
|
("comodor whatsapp start", "the same, from a WhatsApp number"),
|
|
59
60
|
("comodor skills browse", "procedures it follows when the work calls for them"),
|
|
60
61
|
("comodor mcp list", "tools from Model Context Protocol servers"),
|
|
@@ -222,6 +223,33 @@ must be configured before it will start.
|
|
|
222
223
|
In Docker: docker compose up and open the address it prints.
|
|
223
224
|
Full guide: docs/web.md"""),
|
|
224
225
|
|
|
226
|
+
"slack": ("From Slack", """\
|
|
227
|
+
comodor slack manifest the app definition to paste into Slack
|
|
228
|
+
comodor slack connect the two tokens, checked as you paste them
|
|
229
|
+
comodor slack pair a one-time code that adds your account
|
|
230
|
+
comodor slack start -b run it detached from this terminal
|
|
231
|
+
comodor slack status what is set, who may talk, is it running
|
|
232
|
+
|
|
233
|
+
About five minutes. Slack takes an app *manifest*, so the app is one paste
|
|
234
|
+
rather than a page of checkboxes - name, scopes, events and Socket Mode all
|
|
235
|
+
switched on already.
|
|
236
|
+
|
|
237
|
+
Socket Mode is why this needs no public address: the app opens a websocket
|
|
238
|
+
outward rather than being posted to. That is the whole difference between this
|
|
239
|
+
and WhatsApp, which needs a certificate and a tunnel.
|
|
240
|
+
|
|
241
|
+
Two tokens, and they are not interchangeable:
|
|
242
|
+
|
|
243
|
+
xoxb-... the bot token, from OAuth & Permissions. Does the work.
|
|
244
|
+
xapp-... the app-level token, from Basic Information, scope
|
|
245
|
+
connections:write. Opens the socket, and nothing else.
|
|
246
|
+
|
|
247
|
+
In a channel it answers when mentioned; in a direct message, always. It answers
|
|
248
|
+
a fixed list of accounts and nobody else - a workspace can have hundreds of
|
|
249
|
+
people in it, and this reads and writes your files.
|
|
250
|
+
|
|
251
|
+
Full guide: docs/slack.md"""),
|
|
252
|
+
|
|
225
253
|
"whatsapp": ("From WhatsApp", """\
|
|
226
254
|
comodor whatsapp connect guided: links each page, checks each value
|
|
227
255
|
comodor whatsapp webhook what to paste into Meta's dashboard
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""RFC 6455, client side, over TCP or TLS.
|
|
2
|
+
|
|
3
|
+
Lifted out of the browser package, where it was written for Chrome's debugging
|
|
4
|
+
protocol and grew a second caller: Slack's Socket Mode, which is a websocket
|
|
5
|
+
the app opens outward so that no public URL is needed. Two hand-rolled
|
|
6
|
+
implementations of the same frame format in one wheel would be one too many —
|
|
7
|
+
and the browser's could not have served, because it speaks plain TCP and Slack
|
|
8
|
+
is `wss://`.
|
|
9
|
+
|
|
10
|
+
Only what a client actually needs. No extensions, no compression, no
|
|
11
|
+
fragmented *sends* — every caller here sends JSON text, which fits one frame.
|
|
12
|
+
Incoming fragments are reassembled, because a large accessibility tree and a
|
|
13
|
+
busy Slack workspace both arrive in pieces.
|
|
14
|
+
|
|
15
|
+
The masking is not optional and is a common thing to get wrong: a client must
|
|
16
|
+
mask every frame it sends and a server must not mask what it sends back.
|
|
17
|
+
Chrome enforces the first and Slack enforces both.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import os
|
|
24
|
+
import socket
|
|
25
|
+
import ssl
|
|
26
|
+
import struct
|
|
27
|
+
import threading
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
DEFAULT_TIMEOUT = 30.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebSocketError(RuntimeError):
|
|
34
|
+
"""The connection failed, refused the upgrade, or went away."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WebSocket:
|
|
38
|
+
"""One connection. Thread-safe to send on, single-reader to receive."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, url: str, timeout: float = DEFAULT_TIMEOUT,
|
|
41
|
+
headers: dict[str, str] | None = None) -> None:
|
|
42
|
+
parts = urlsplit(url)
|
|
43
|
+
secure = parts.scheme in ("wss", "https")
|
|
44
|
+
host = parts.hostname or ""
|
|
45
|
+
port = parts.port or (443 if secure else 80)
|
|
46
|
+
path = parts.path or "/"
|
|
47
|
+
if parts.query:
|
|
48
|
+
path = f"{path}?{parts.query}"
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
raw = socket.create_connection((host, port), timeout=timeout)
|
|
52
|
+
except OSError as error:
|
|
53
|
+
raise WebSocketError(f"could not connect to {host}: {error}") \
|
|
54
|
+
from error
|
|
55
|
+
|
|
56
|
+
if secure:
|
|
57
|
+
try:
|
|
58
|
+
context = ssl.create_default_context()
|
|
59
|
+
raw = context.wrap_socket(raw, server_hostname=host)
|
|
60
|
+
except OSError as error:
|
|
61
|
+
raw.close()
|
|
62
|
+
raise WebSocketError(f"TLS to {host} failed: {error}") from error
|
|
63
|
+
|
|
64
|
+
self._socket = raw
|
|
65
|
+
self._socket.settimeout(timeout)
|
|
66
|
+
self._buffer = b""
|
|
67
|
+
self._send_lock = threading.Lock()
|
|
68
|
+
|
|
69
|
+
key = base64.b64encode(os.urandom(16)).decode()
|
|
70
|
+
request = [
|
|
71
|
+
f"GET {path} HTTP/1.1",
|
|
72
|
+
f"Host: {host}" + ("" if port in (80, 443) else f":{port}"),
|
|
73
|
+
"Upgrade: websocket",
|
|
74
|
+
"Connection: Upgrade",
|
|
75
|
+
f"Sec-WebSocket-Key: {key}",
|
|
76
|
+
"Sec-WebSocket-Version: 13",
|
|
77
|
+
]
|
|
78
|
+
for name, value in (headers or {}).items():
|
|
79
|
+
request.append(f"{name}: {value}")
|
|
80
|
+
try:
|
|
81
|
+
self._socket.sendall(("\r\n".join(request) + "\r\n\r\n").encode())
|
|
82
|
+
except OSError as error:
|
|
83
|
+
raise WebSocketError(f"could not send the handshake: {error}") \
|
|
84
|
+
from error
|
|
85
|
+
|
|
86
|
+
header = b""
|
|
87
|
+
while b"\r\n\r\n" not in header:
|
|
88
|
+
try:
|
|
89
|
+
chunk = self._socket.recv(1)
|
|
90
|
+
except OSError as error:
|
|
91
|
+
raise WebSocketError(f"handshake failed: {error}") from error
|
|
92
|
+
if not chunk:
|
|
93
|
+
raise WebSocketError("closed during the handshake")
|
|
94
|
+
header += chunk
|
|
95
|
+
if len(header) > 8192:
|
|
96
|
+
raise WebSocketError("the server sent a handshake we cannot read")
|
|
97
|
+
|
|
98
|
+
first = header.split(b"\r\n")[0]
|
|
99
|
+
if b" 101" not in first:
|
|
100
|
+
raise WebSocketError(f"the upgrade was refused: {first[:120]!r}")
|
|
101
|
+
|
|
102
|
+
# -- frames ------------------------------------------------------------ #
|
|
103
|
+
|
|
104
|
+
def send(self, text: str) -> None:
|
|
105
|
+
payload = text.encode()
|
|
106
|
+
frame = bytearray([0x81]) # FIN + text
|
|
107
|
+
length = len(payload)
|
|
108
|
+
if length < 126:
|
|
109
|
+
frame.append(0x80 | length)
|
|
110
|
+
elif length < 65536:
|
|
111
|
+
frame.append(0x80 | 126)
|
|
112
|
+
frame += struct.pack(">H", length)
|
|
113
|
+
else:
|
|
114
|
+
frame.append(0x80 | 127)
|
|
115
|
+
frame += struct.pack(">Q", length)
|
|
116
|
+
mask = os.urandom(4)
|
|
117
|
+
frame += mask
|
|
118
|
+
frame += bytes(byte ^ mask[index % 4]
|
|
119
|
+
for index, byte in enumerate(payload))
|
|
120
|
+
with self._send_lock:
|
|
121
|
+
try:
|
|
122
|
+
self._socket.sendall(bytes(frame))
|
|
123
|
+
except OSError as error:
|
|
124
|
+
raise WebSocketError(f"the connection went away: {error}") \
|
|
125
|
+
from error
|
|
126
|
+
|
|
127
|
+
def receive(self) -> str:
|
|
128
|
+
"""One complete message, reassembled if it arrived in fragments."""
|
|
129
|
+
parts: list[bytes] = []
|
|
130
|
+
while True:
|
|
131
|
+
first, second = self._exactly(2)
|
|
132
|
+
final = bool(first & 0x80)
|
|
133
|
+
opcode = first & 0x0F
|
|
134
|
+
length = second & 0x7F
|
|
135
|
+
if length == 126:
|
|
136
|
+
length = struct.unpack(">H", self._exactly(2))[0]
|
|
137
|
+
elif length == 127:
|
|
138
|
+
length = struct.unpack(">Q", self._exactly(8))[0]
|
|
139
|
+
mask = self._exactly(4) if second & 0x80 else b""
|
|
140
|
+
payload = self._exactly(length)
|
|
141
|
+
if mask:
|
|
142
|
+
payload = bytes(byte ^ mask[index % 4]
|
|
143
|
+
for index, byte in enumerate(payload))
|
|
144
|
+
|
|
145
|
+
if opcode == 0x8: # close
|
|
146
|
+
raise WebSocketError("the server closed the connection")
|
|
147
|
+
if opcode == 0x9: # ping -> pong
|
|
148
|
+
self._pong(payload)
|
|
149
|
+
continue
|
|
150
|
+
if opcode == 0xA: # pong
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
parts.append(payload)
|
|
154
|
+
if final:
|
|
155
|
+
return b"".join(parts).decode("utf-8", "replace")
|
|
156
|
+
|
|
157
|
+
def _pong(self, payload: bytes) -> None:
|
|
158
|
+
frame = bytearray([0x8A, 0x80 | len(payload)])
|
|
159
|
+
mask = os.urandom(4)
|
|
160
|
+
frame += mask
|
|
161
|
+
frame += bytes(byte ^ mask[index % 4]
|
|
162
|
+
for index, byte in enumerate(payload))
|
|
163
|
+
with self._send_lock:
|
|
164
|
+
try:
|
|
165
|
+
self._socket.sendall(bytes(frame))
|
|
166
|
+
except OSError:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
def ping(self, payload: bytes = b"") -> None:
|
|
170
|
+
"""Say we are still here.
|
|
171
|
+
|
|
172
|
+
Slack closes a socket it has not heard from, and its own pings are not
|
|
173
|
+
enough on a quiet workspace — a bot nobody messaged for an hour is
|
|
174
|
+
exactly the case that must survive.
|
|
175
|
+
"""
|
|
176
|
+
frame = bytearray([0x89, 0x80 | len(payload)])
|
|
177
|
+
mask = os.urandom(4)
|
|
178
|
+
frame += mask
|
|
179
|
+
frame += bytes(byte ^ mask[index % 4]
|
|
180
|
+
for index, byte in enumerate(payload))
|
|
181
|
+
with self._send_lock:
|
|
182
|
+
try:
|
|
183
|
+
self._socket.sendall(bytes(frame))
|
|
184
|
+
except OSError as error:
|
|
185
|
+
raise WebSocketError(f"the connection went away: {error}") \
|
|
186
|
+
from error
|
|
187
|
+
|
|
188
|
+
def _exactly(self, count: int) -> bytes:
|
|
189
|
+
while len(self._buffer) < count:
|
|
190
|
+
try:
|
|
191
|
+
chunk = self._socket.recv(65536)
|
|
192
|
+
except socket.timeout as error:
|
|
193
|
+
raise WebSocketError("the server stopped answering") from error
|
|
194
|
+
except OSError as error:
|
|
195
|
+
raise WebSocketError(f"the connection went away: {error}") \
|
|
196
|
+
from error
|
|
197
|
+
if not chunk:
|
|
198
|
+
raise WebSocketError("the server closed the connection")
|
|
199
|
+
self._buffer += chunk
|
|
200
|
+
taken, self._buffer = self._buffer[:count], self._buffer[count:]
|
|
201
|
+
return taken
|
|
202
|
+
|
|
203
|
+
def settimeout(self, timeout: float) -> None:
|
|
204
|
+
self._socket.settimeout(timeout)
|
|
205
|
+
|
|
206
|
+
def close(self) -> None:
|
|
207
|
+
try:
|
|
208
|
+
self._socket.close()
|
|
209
|
+
except OSError:
|
|
210
|
+
pass
|