comodor 0.19.0__tar.gz → 0.19.2__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.0 → comodor-0.19.2}/PKG-INFO +2 -2
- {comodor-0.19.0 → comodor-0.19.2}/README.md +1 -1
- {comodor-0.19.0 → comodor-0.19.2}/docs/README.md +1 -1
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/_version.py +2 -2
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/help.py +16 -3
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/setup.py +18 -7
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/commands.py +57 -2
- comodor-0.19.2/src/comodor/whatsapp/guide.py +322 -0
- comodor-0.19.2/src/comodor/whatsapp/tunnel.py +218 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_whatsapp.py +140 -0
- {comodor-0.19.0 → comodor-0.19.2}/.gitignore +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/LICENSE +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/pyproject.toml +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/__main__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/acp/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/acp/agent.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/acp/commands.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/acp/jsonrpc.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/context.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/loop.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/prompts.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/spawn.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/tokens.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/browser/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/browser/cdp.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/browser/launch.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/browser/page.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/catalogue.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/channels/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/channels/daemon.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/channels/unit.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/cli.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/config.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/guard.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/keys.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/overlay.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/png.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/screen.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/win32.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/doctor.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/events.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/associations.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/bm25.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/hotindex.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/memory.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/progress.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/reflect.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/rules.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/signals.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/store.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/writer.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/catalogue.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/commands.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/download.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/models.json +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/provider.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/runtime.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/store.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/catalogue.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/commands.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/http.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/manager.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/protocol.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/migrate.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/net/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/net/http.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/net/sse.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/paths.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/anthropic.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/base.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/caching.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/discover.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/fake.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/gateway.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/models.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/oauth.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/openai_compat.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/registry.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/questions.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/safety/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/safety/checkpoints.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/safety/permissions.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/safety/redact.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/session/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/session/search.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/session/store.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/catalogue.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/commands.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/examples.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/loader.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/propose.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/registry.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/api.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/bot.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/commands.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/keyboard.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/service.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/unit.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/ask.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/base.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/browse.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/browser.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/computer.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/delegate.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/fs.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/history.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/mcp.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/overflow.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/registry.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/search.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/shell.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/skills.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/todo.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/web.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/app.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/banner.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/bidi.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/chooser.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/clipboard.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/console.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/input/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/input/keys.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/input/reader.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/layout.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/markdown.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/screen.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/theme.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/buttons.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/chat.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/history.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/overlay.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/panel.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/progress.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/prompt.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/questions.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/statusbar.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/toast.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/welcome.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/uninstall.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/update.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/commands.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/index.html +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/server.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/session.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/ui.css +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/ui.js +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/vazirmatn-OFL.txt +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/vazirmatn.woff2 +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/__init__.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/api.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/bot.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/menu.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/service.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/unit.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/webhook.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/src/comodor/workspace.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/conftest.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/support/fake_mcp_server.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_acp.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_agent_loop.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_app.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_associations.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_banner.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_bidi.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_browser.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_caching.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_channel_service.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_chooser.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_computer_tool.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_config_save.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_copy_and_bands.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_delegate.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_desktop.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_discover.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_doctor.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_help.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_history.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_import_command.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_input.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_layout.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_learning.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_local.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_markdown.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_mcp.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_mcp_http.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_migrate.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_oauth.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_overflow.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_overlay.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_performance.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_prefix_stability.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_progress.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_propose.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_providers.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_questions.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_real_browser.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_real_desktop.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_real_web_ui.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_reflex.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_run_loop.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_setup.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_setup_import.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_skill_budget.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_skill_catalogue.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_skills.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_switching_model.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_telegram.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_tools.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_uninstall.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_update.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_web.py +0 -0
- {comodor-0.19.0 → comodor-0.19.2}/tests/test_welcome.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: comodor
|
|
3
|
-
Version: 0.19.
|
|
3
|
+
Version: 0.19.2
|
|
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
|
|
@@ -174,7 +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 WhatsApp](docs/whatsapp.md) | The Cloud API
|
|
177
|
+
| [From WhatsApp](docs/whatsapp.md) | The Cloud API — longer to set up; Telegram does the same thing |
|
|
178
178
|
| [Models on your machine](docs/local-models.md) | Downloading one, running it offline |
|
|
179
179
|
| [Configuration](docs/configuration.md) | Every setting, and what wins |
|
|
180
180
|
| [Cost](docs/cost.md) | Caching, budgets, paying less |
|
|
@@ -147,7 +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 WhatsApp](docs/whatsapp.md) | The Cloud API
|
|
150
|
+
| [From WhatsApp](docs/whatsapp.md) | The Cloud API — longer to set up; Telegram does the same thing |
|
|
151
151
|
| [Models on your machine](docs/local-models.md) | Downloading one, running it offline |
|
|
152
152
|
| [Configuration](docs/configuration.md) | Every setting, and what wins |
|
|
153
153
|
| [Cost](docs/cost.md) | Caching, budgets, paying less |
|
|
@@ -42,7 +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 WhatsApp](whatsapp.md) | The Cloud API
|
|
45
|
+
| [From WhatsApp](whatsapp.md) | The Cloud API — about twenty minutes and technical. Telegram does the same in one |
|
|
46
46
|
| [Models on your machine](local-models.md) | Downloading one, running it offline, adding to the list |
|
|
47
47
|
| [Questions](questions.md) | The form it puts up when a request reads two ways |
|
|
48
48
|
| [How it learns](learning.md) | Corrections, lessons, rules, and the proof |
|
|
@@ -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.19.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 19,
|
|
21
|
+
__version__ = version = '0.19.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 19, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -223,12 +223,21 @@ In Docker: docker compose up and open the address it prints.
|
|
|
223
223
|
Full guide: docs/web.md"""),
|
|
224
224
|
|
|
225
225
|
"whatsapp": ("From WhatsApp", """\
|
|
226
|
-
comodor whatsapp connect
|
|
226
|
+
comodor whatsapp connect guided: links each page, checks each value
|
|
227
227
|
comodor whatsapp webhook what to paste into Meta's dashboard
|
|
228
228
|
comodor whatsapp pair a one-time code that adds your number
|
|
229
229
|
comodor whatsapp start -b run it detached from this terminal
|
|
230
230
|
comodor whatsapp status what is set, who may talk, is it running
|
|
231
231
|
|
|
232
|
+
Telegram does the same thing in about a minute. This takes about twenty and is
|
|
233
|
+
technical, and most of it is in Meta's dashboard - so unless it has to be
|
|
234
|
+
WhatsApp, `comodor help telegram` is the shorter road.
|
|
235
|
+
|
|
236
|
+
What is NOT needed, because everybody assumes otherwise: no real phone number,
|
|
237
|
+
no payment method, no business verification. The test number Meta creates
|
|
238
|
+
messages five people for free, which is four more than one person talking to
|
|
239
|
+
their own agent needs.
|
|
240
|
+
|
|
232
241
|
Meta's Cloud API, which is the official one. The libraries that drive WhatsApp
|
|
233
242
|
Web instead need Node, break whenever the web client changes, and are against
|
|
234
243
|
the terms the account is held to - the failure mode there is somebody's phone
|
|
@@ -238,9 +247,13 @@ Two things work differently from Telegram, and both are Meta's design:
|
|
|
238
247
|
|
|
239
248
|
*Messages are delivered, not fetched.* There is no long poll. Meta posts each
|
|
240
249
|
message to a URL, so something of yours has to be reachable over HTTPS. The
|
|
241
|
-
bot listens on localhost;
|
|
250
|
+
bot listens on localhost; `connect` starts a Cloudflare tunnel in front of it
|
|
251
|
+
if cloudflared is installed, and `start --tunnel` brings one up alongside.
|
|
252
|
+
|
|
253
|
+
A quick tunnel gets a new address every start, and Meta keeps delivering to
|
|
254
|
+
the old one - so for a bot meant to keep running, make a named tunnel once:
|
|
242
255
|
|
|
243
|
-
cloudflared tunnel
|
|
256
|
+
cloudflared tunnel login && cloudflared tunnel create comodor
|
|
244
257
|
|
|
245
258
|
*Every webhook is signed* with your app secret, and the signature is checked.
|
|
246
259
|
Without one, anything that reaches that URL can hand the agent instructions
|
|
@@ -660,12 +660,21 @@ class SetupWizard:
|
|
|
660
660
|
"otherwise.\n",
|
|
661
661
|
style=self.theme.style("dim")))
|
|
662
662
|
|
|
663
|
+
# The two are not equal work and the list says so. WhatsApp needs a
|
|
664
|
+
# Meta app, an app secret and a public HTTPS address — twenty minutes
|
|
665
|
+
# of somebody else's dashboard — and it does exactly what the one-token
|
|
666
|
+
# option does. Somebody choosing it because it was listed second, and
|
|
667
|
+
# discovering that twenty minutes later, is a question that cost them
|
|
668
|
+
# their evening.
|
|
663
669
|
wanted = self._choose(
|
|
664
670
|
[("no", "Not now", "you can set either up later"),
|
|
665
671
|
("telegram", "Telegram",
|
|
666
|
-
"
|
|
672
|
+
"recommended — one token from @BotFather, about a minute, "
|
|
673
|
+
"nothing else to set up"),
|
|
667
674
|
("whatsapp", "WhatsApp",
|
|
668
|
-
"
|
|
675
|
+
"about twenty minutes and technical: a Meta app, an app secret "
|
|
676
|
+
"and a public HTTPS address. It does the same thing Telegram "
|
|
677
|
+
"does — pick this only if it has to be WhatsApp")],
|
|
669
678
|
default=1, title="From your phone")
|
|
670
679
|
if wanted == "whatsapp":
|
|
671
680
|
self._point_at_whatsapp(step, total)
|
|
@@ -728,11 +737,13 @@ class SetupWizard:
|
|
|
728
737
|
"number, an access token, an\napp secret, and a public HTTPS "
|
|
729
738
|
"address for Meta to deliver to.\n\n"
|
|
730
739
|
"None of that can be made from a terminal, so it is its own "
|
|
731
|
-
"command
|
|
732
|
-
"
|
|
733
|
-
"
|
|
734
|
-
" [bold]comodor whatsapp
|
|
735
|
-
"[dim]
|
|
740
|
+
"command — and\nthat one walks you through it, links each "
|
|
741
|
+
"page, checks each value as it\narrives, and starts the "
|
|
742
|
+
"tunnel for you:\n\n"
|
|
743
|
+
" [bold]comodor whatsapp connect[/bold]\n\n"
|
|
744
|
+
"[dim]About twenty minutes the first time. No real number, no "
|
|
745
|
+
"card and no\nbusiness verification — the test number Meta "
|
|
746
|
+
"makes you messages five\npeople for free.[/dim]"),
|
|
736
747
|
title=Text(" WhatsApp ", style=self.theme.style("title")),
|
|
737
748
|
title_align="left", box=self.theme.box,
|
|
738
749
|
border_style=self.theme.style("border"), padding=(1, 2)))
|
|
@@ -34,11 +34,15 @@ def register(subparsers: argparse._SubParsersAction) -> None:
|
|
|
34
34
|
help="from the app's Basic Settings")
|
|
35
35
|
connect.add_argument("--url", dest="public_url",
|
|
36
36
|
help="the public HTTPS address of the webhook")
|
|
37
|
+
connect.add_argument("--tunnel", action="store_true",
|
|
38
|
+
help="start a Cloudflare tunnel and use its address")
|
|
37
39
|
|
|
38
40
|
start = actions.add_parser("start", help="run the bot")
|
|
39
41
|
start.add_argument("--background", "-b", action="store_true",
|
|
40
42
|
help="detach from this terminal, so it keeps answering "
|
|
41
43
|
"after you close it")
|
|
44
|
+
start.add_argument("--tunnel", action="store_true",
|
|
45
|
+
help="bring up a Cloudflare tunnel alongside it")
|
|
42
46
|
|
|
43
47
|
actions.add_parser("stop", help="stop a bot running in the background")
|
|
44
48
|
|
|
@@ -86,7 +90,7 @@ def run(config: Config, args: argparse.Namespace) -> int:
|
|
|
86
90
|
if action == "start":
|
|
87
91
|
if getattr(args, "background", False):
|
|
88
92
|
return _background(console, config)
|
|
89
|
-
return _start(console, config)
|
|
93
|
+
return _start(console, config, args)
|
|
90
94
|
if action == "stop":
|
|
91
95
|
return _stop(console, config)
|
|
92
96
|
if action == "service":
|
|
@@ -96,6 +100,20 @@ def run(config: Config, args: argparse.Namespace) -> int:
|
|
|
96
100
|
return 1
|
|
97
101
|
|
|
98
102
|
|
|
103
|
+
def _ask(console, message: str) -> str:
|
|
104
|
+
"""One line from the person at the terminal, or empty if there is nobody.
|
|
105
|
+
|
|
106
|
+
Empty rather than an exception on end-of-file: the wizard treats "nothing
|
|
107
|
+
entered" as "stop here and keep what we have", which is the right answer
|
|
108
|
+
for a pipe as well as for somebody pressing enter.
|
|
109
|
+
"""
|
|
110
|
+
console.print(f" [bold]{message}[/bold][dim]:[/dim] ", end="")
|
|
111
|
+
try:
|
|
112
|
+
return input()
|
|
113
|
+
except (EOFError, KeyboardInterrupt):
|
|
114
|
+
return ""
|
|
115
|
+
|
|
116
|
+
|
|
99
117
|
def _save(config: Config) -> None:
|
|
100
118
|
from .. import config as config_mod
|
|
101
119
|
|
|
@@ -113,6 +131,19 @@ def _connect(console, config: Config, args: argparse.Namespace) -> int:
|
|
|
113
131
|
number_id = (args.number_id or "").strip()
|
|
114
132
|
secret = (args.app_secret or "").strip()
|
|
115
133
|
|
|
134
|
+
if not (token or number_id or secret):
|
|
135
|
+
# Nothing given at all: walk it, rather than printing instructions and
|
|
136
|
+
# leaving somebody to run the command again with three flags they have
|
|
137
|
+
# to go and find first.
|
|
138
|
+
from ..ui import console as console_module
|
|
139
|
+
from .guide import walk
|
|
140
|
+
|
|
141
|
+
theme = console_module.prepare_theme(
|
|
142
|
+
config.ui.theme, config.ui.ascii_borders, no_color=False)
|
|
143
|
+
return walk(console, theme, config,
|
|
144
|
+
ask=lambda message: _ask(console, message),
|
|
145
|
+
save=_save)
|
|
146
|
+
|
|
116
147
|
if not (token and number_id):
|
|
117
148
|
console.print()
|
|
118
149
|
console.print(Panel(
|
|
@@ -372,7 +403,7 @@ def _off(console, config: Config) -> int:
|
|
|
372
403
|
return 0
|
|
373
404
|
|
|
374
405
|
|
|
375
|
-
def _start(console, config: Config) -> int:
|
|
406
|
+
def _start(console, config: Config, args: argparse.Namespace) -> int:
|
|
376
407
|
from ..channels import WHATSAPP
|
|
377
408
|
from .bot import Service
|
|
378
409
|
|
|
@@ -390,11 +421,35 @@ def _start(console, config: Config) -> int:
|
|
|
390
421
|
console.print()
|
|
391
422
|
console.print(f" Working in [bold]{config.paths.project}[/bold]")
|
|
392
423
|
|
|
424
|
+
opened = None
|
|
425
|
+
if getattr(args, "tunnel", False):
|
|
426
|
+
from . import tunnel as tunnel_mod
|
|
427
|
+
|
|
428
|
+
opened, why = tunnel_mod.start_quick(config.whatsapp.port,
|
|
429
|
+
config.whatsapp.host)
|
|
430
|
+
if opened is None:
|
|
431
|
+
console.print(f" [red]{why}[/red]\n")
|
|
432
|
+
return 1
|
|
433
|
+
where = opened.webhook(config.whatsapp.path)
|
|
434
|
+
console.print(f" Tunnel [bold]{where}[/bold]")
|
|
435
|
+
if where != config.whatsapp.public_url:
|
|
436
|
+
# A quick tunnel gets a new hostname every run and Meta keeps
|
|
437
|
+
# delivering to the old one. Saying nothing here produces a bot
|
|
438
|
+
# that starts cleanly and never receives anything.
|
|
439
|
+
console.print(" [warn]This is not the address Meta has.[/warn] "
|
|
440
|
+
"Update the Callback URL in")
|
|
441
|
+
console.print(" [dim]the dashboard, or make a named tunnel for "
|
|
442
|
+
"an address that does not move.[/dim]")
|
|
443
|
+
config.whatsapp.public_url = where
|
|
444
|
+
_save(config)
|
|
445
|
+
|
|
393
446
|
service = Service(config, announce=lambda line: console.print(f" {line}"))
|
|
394
447
|
|
|
395
448
|
def bye(*_: object) -> None:
|
|
396
449
|
console.print("\n Stopping.\n")
|
|
397
450
|
service.stop()
|
|
451
|
+
if opened is not None:
|
|
452
|
+
opened.stop()
|
|
398
453
|
|
|
399
454
|
try:
|
|
400
455
|
signal.signal(signal.SIGINT, lambda *_: bye())
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""`comodor whatsapp connect`, walked through rather than documented.
|
|
2
|
+
|
|
3
|
+
WhatsApp needs eight things done in a browser and a terminal, in order, and
|
|
4
|
+
getting any one of them wrong produces a failure somewhere else entirely: a
|
|
5
|
+
phone *number* pasted where the number *id* goes fails at the first send with
|
|
6
|
+
"Unsupported post request"; a token from the dashboard works today and stops
|
|
7
|
+
tomorrow; an app secret that never got saved makes every webhook unverified,
|
|
8
|
+
which looks exactly like a bot nobody is talking to.
|
|
9
|
+
|
|
10
|
+
So each value is taken one at a time and checked the moment it arrives — the
|
|
11
|
+
token against Meta, the id for being an id, the secret for being a secret — and
|
|
12
|
+
the two hard parts are done for you: the tunnel is started here, and Meta's
|
|
13
|
+
verification callback is *waited for* rather than assumed, so the step ends
|
|
14
|
+
when Meta has actually reached the endpoint and not when somebody has pressed
|
|
15
|
+
enter.
|
|
16
|
+
|
|
17
|
+
The wizard is the whole of `connect` when it is given no arguments. With them
|
|
18
|
+
it is the one-line form, which is what a second machine or a script wants.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any, Callable
|
|
26
|
+
|
|
27
|
+
from rich.panel import Panel
|
|
28
|
+
from rich.text import Text
|
|
29
|
+
|
|
30
|
+
from ..config import Config
|
|
31
|
+
from .api import Cloud, Unauthorised, WhatsAppError
|
|
32
|
+
from .webhook import Endpoint, make_verify_token
|
|
33
|
+
|
|
34
|
+
#: How long to wait for Meta to call the webhook back after somebody saves it.
|
|
35
|
+
#: Meta calls within a second or two; a minute allows for reading the screen.
|
|
36
|
+
VERIFY_PATIENCE = 180.0
|
|
37
|
+
|
|
38
|
+
APPS = "https://developers.facebook.com/apps"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Step:
|
|
43
|
+
"""One thing to do, and where."""
|
|
44
|
+
|
|
45
|
+
title: str
|
|
46
|
+
body: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _panel(console, theme, title: str, body: str) -> None:
|
|
50
|
+
console.print(Panel(Text.from_markup(body),
|
|
51
|
+
title=Text(f" {title} ",
|
|
52
|
+
style=theme.style("title")),
|
|
53
|
+
title_align="left", box=theme.box,
|
|
54
|
+
border_style=theme.style("border"), padding=(1, 2)))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def walk(console, theme, config: Config,
|
|
58
|
+
ask: Callable[[str], str],
|
|
59
|
+
save: Callable[[Config], None]) -> int:
|
|
60
|
+
"""The whole of it, one step at a time. Returns an exit code."""
|
|
61
|
+
settings = config.whatsapp
|
|
62
|
+
|
|
63
|
+
# -- 1. the app ------------------------------------------------------- #
|
|
64
|
+
console.print()
|
|
65
|
+
_panel(console, theme, "1 of 5 — a Meta app",
|
|
66
|
+
f"Open [bold]{APPS}[/bold] and:\n\n"
|
|
67
|
+
" • [bold]Create app[/bold] → type [bold]Business[/bold]\n"
|
|
68
|
+
" • inside it, [bold]Add product[/bold] → [bold]WhatsApp[/bold] "
|
|
69
|
+
"→ Set up\n\n"
|
|
70
|
+
"Meta then makes you a [bold]test business number[/bold] and a test "
|
|
71
|
+
"account\nautomatically. No real number, no card, no business "
|
|
72
|
+
"verification —\nthe test number messages up to five people, which "
|
|
73
|
+
"is four more than\nyou need.\n\n"
|
|
74
|
+
"[dim]On that page you will see 'Phone number ID' and a temporary "
|
|
75
|
+
"token.[/dim]")
|
|
76
|
+
if ask("press enter when the WhatsApp product is added") is None:
|
|
77
|
+
return 130
|
|
78
|
+
|
|
79
|
+
# -- 2. the number id -------------------------------------------------- #
|
|
80
|
+
console.print()
|
|
81
|
+
_panel(console, theme, "2 of 5 — the phone number id",
|
|
82
|
+
"On [bold]WhatsApp → API Setup[/bold], copy [bold]Phone number ID"
|
|
83
|
+
"[/bold].\n\n"
|
|
84
|
+
"[dim]It is a long number under 'From'. Not the phone number "
|
|
85
|
+
"itself —\nthat is the single most common mistake here, and it "
|
|
86
|
+
"fails later\nwith an error that does not mention it.[/dim]")
|
|
87
|
+
number_id = ""
|
|
88
|
+
while True:
|
|
89
|
+
number_id = (ask("phone number id") or "").strip()
|
|
90
|
+
if not number_id:
|
|
91
|
+
console.print(" [dim]Nothing entered — stopping here. Run "
|
|
92
|
+
"`comodor whatsapp connect` again when ready.[/dim]\n")
|
|
93
|
+
return 1
|
|
94
|
+
if number_id.isdigit() and len(number_id) >= 10:
|
|
95
|
+
break
|
|
96
|
+
bare = number_id.replace("+", "").replace(" ", "").replace("-", "")
|
|
97
|
+
if number_id.lstrip("+").replace(" ", "").replace("-", "").isdigit() \
|
|
98
|
+
and 7 <= len(bare) <= 15 and not number_id.isdigit():
|
|
99
|
+
# Written the way a phone number is written — spaces, dashes, a
|
|
100
|
+
# leading plus — which is the mistake worth naming, because it
|
|
101
|
+
# otherwise fails much later with an error about a "post request".
|
|
102
|
+
console.print(" [red]That looks like the phone number.[/red] "
|
|
103
|
+
"The id is the long number beside it.")
|
|
104
|
+
elif not bare.isdigit():
|
|
105
|
+
console.print(" [red]An id is digits only.[/red]")
|
|
106
|
+
else:
|
|
107
|
+
console.print(f" [red]Too short for an id[/red] [dim]({len(bare)} "
|
|
108
|
+
f"digits; they are fifteen or so).[/dim]")
|
|
109
|
+
|
|
110
|
+
# -- 3. the token ------------------------------------------------------ #
|
|
111
|
+
console.print()
|
|
112
|
+
_panel(console, theme, "3 of 5 — an access token",
|
|
113
|
+
"The token on the API Setup page works, and [bold]expires in "
|
|
114
|
+
"twenty-four\nhours[/bold] — fine for trying this out, and it will "
|
|
115
|
+
"look like a bug tomorrow.\n\n"
|
|
116
|
+
"For one that does not expire:\n"
|
|
117
|
+
" • [bold]business.facebook.com[/bold] → Business settings → "
|
|
118
|
+
"[bold]System users[/bold]\n"
|
|
119
|
+
" • add one, [bold]Generate token[/bold], pick your app\n"
|
|
120
|
+
" • tick [bold]whatsapp_business_messaging[/bold] and "
|
|
121
|
+
"[bold]whatsapp_business_management[/bold]\n\n"
|
|
122
|
+
"[dim]Either will do now; you can paste a permanent one later with"
|
|
123
|
+
"\n`comodor whatsapp connect --token …`.[/dim]")
|
|
124
|
+
|
|
125
|
+
token = ""
|
|
126
|
+
while True:
|
|
127
|
+
token = (ask("access token") or "").strip()
|
|
128
|
+
if not token:
|
|
129
|
+
console.print(" [dim]Stopping here.[/dim]\n")
|
|
130
|
+
return 1
|
|
131
|
+
console.print(" [dim]checking it with Meta…[/dim]")
|
|
132
|
+
try:
|
|
133
|
+
me = Cloud(token, number_id,
|
|
134
|
+
version=settings.api_version).me()
|
|
135
|
+
except Unauthorised as problem:
|
|
136
|
+
console.print(f" [red]Meta refused it.[/red] {problem}")
|
|
137
|
+
continue
|
|
138
|
+
except WhatsAppError as problem:
|
|
139
|
+
console.print(f" [red]{problem}[/red]")
|
|
140
|
+
continue
|
|
141
|
+
shown = me.get("display_phone_number") or number_id
|
|
142
|
+
named = me.get("verified_name") or ""
|
|
143
|
+
console.print(f" [green]Works.[/green] {shown}"
|
|
144
|
+
+ (f" ([dim]{named}[/dim])" if named else ""))
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
# -- 4. the app secret -------------------------------------------------- #
|
|
148
|
+
console.print()
|
|
149
|
+
_panel(console, theme, "4 of 5 — the app secret",
|
|
150
|
+
"In the same app: [bold]Settings → Basic[/bold], then [bold]Show"
|
|
151
|
+
"[/bold] beside App secret.\n\n"
|
|
152
|
+
"[dim]Every webhook Meta sends is signed with it, and Comodor "
|
|
153
|
+
"refuses any\ndelivery it cannot verify. Without one, anything that "
|
|
154
|
+
"reaches the\nendpoint could hand the agent instructions with your "
|
|
155
|
+
"number on them.[/dim]")
|
|
156
|
+
secret = ""
|
|
157
|
+
while True:
|
|
158
|
+
secret = (ask("app secret") or "").strip()
|
|
159
|
+
if not secret:
|
|
160
|
+
console.print(" [red]Without this nothing can be verified.[/red] "
|
|
161
|
+
"[dim]Stopping here.[/dim]\n")
|
|
162
|
+
return 1
|
|
163
|
+
if len(secret) >= 24 and all(c in "0123456789abcdefABCDEF"
|
|
164
|
+
for c in secret):
|
|
165
|
+
break
|
|
166
|
+
console.print(" [red]That does not look like an app secret[/red] "
|
|
167
|
+
"[dim](32 hex characters).[/dim]")
|
|
168
|
+
|
|
169
|
+
settings.token = token
|
|
170
|
+
settings.phone_number_id = number_id
|
|
171
|
+
settings.app_secret = secret
|
|
172
|
+
if not settings.verify_token:
|
|
173
|
+
settings.verify_token = make_verify_token()
|
|
174
|
+
settings.enabled = True
|
|
175
|
+
save(config)
|
|
176
|
+
console.print(" [dim]saved[/dim]")
|
|
177
|
+
|
|
178
|
+
# -- 5. somewhere for Meta to deliver to -------------------------------- #
|
|
179
|
+
return _webhook_step(console, theme, config, ask, save)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _webhook_step(console, theme, config: Config,
|
|
183
|
+
ask: Callable[[str], str],
|
|
184
|
+
save: Callable[[Config], None]) -> int:
|
|
185
|
+
"""The hard one: a public HTTPS address, and Meta actually reaching it."""
|
|
186
|
+
from . import tunnel as tunnel_mod
|
|
187
|
+
|
|
188
|
+
settings = config.whatsapp
|
|
189
|
+
console.print()
|
|
190
|
+
_panel(console, theme, "5 of 5 — where Meta delivers",
|
|
191
|
+
"Meta posts each message to a URL, so something has to be "
|
|
192
|
+
"reachable from\nthe internet over HTTPS. Comodor listens on "
|
|
193
|
+
f"[bold]{settings.host}:{settings.port}[/bold];\nthe usual answer "
|
|
194
|
+
"is a Cloudflare tunnel, which needs no open port and\nno domain.")
|
|
195
|
+
|
|
196
|
+
started: Any = None
|
|
197
|
+
where = settings.public_url
|
|
198
|
+
|
|
199
|
+
binary = tunnel_mod.find_binary()
|
|
200
|
+
if binary is None:
|
|
201
|
+
console.print(f" [dim]cloudflared is not installed — "
|
|
202
|
+
f"{tunnel_mod.how_to_install()}[/dim]")
|
|
203
|
+
console.print(" [dim]Or use any address that already reaches this "
|
|
204
|
+
"machine over HTTPS.[/dim]")
|
|
205
|
+
where = (ask(f"public https address for {settings.path}")
|
|
206
|
+
or "").strip()
|
|
207
|
+
if not where:
|
|
208
|
+
return _unfinished(console, config, save)
|
|
209
|
+
else:
|
|
210
|
+
console.print(f" [dim]cloudflared: {binary}[/dim]")
|
|
211
|
+
console.print(" [dim]starting a tunnel…[/dim]")
|
|
212
|
+
started, why = tunnel_mod.start_quick(settings.port, settings.host)
|
|
213
|
+
if started is None:
|
|
214
|
+
console.print(f" [red]{why}[/red]")
|
|
215
|
+
where = (ask(f"public https address for {settings.path}")
|
|
216
|
+
or "").strip()
|
|
217
|
+
if not where:
|
|
218
|
+
return _unfinished(console, config, save)
|
|
219
|
+
else:
|
|
220
|
+
where = started.webhook(settings.path)
|
|
221
|
+
console.print(f" [green]tunnel up[/green] {where}")
|
|
222
|
+
console.print(Text(
|
|
223
|
+
" This address is temporary — a quick tunnel gets a new one "
|
|
224
|
+
"every time it\n starts, so Meta would have to be told again "
|
|
225
|
+
"after a restart. For a bot\n meant to keep running, make a "
|
|
226
|
+
"named tunnel once:",
|
|
227
|
+
style=theme.style("dim")))
|
|
228
|
+
console.print(Text(
|
|
229
|
+
" cloudflared tunnel login && cloudflared tunnel create "
|
|
230
|
+
"comodor",
|
|
231
|
+
style=theme.style("accent")))
|
|
232
|
+
|
|
233
|
+
settings.public_url = where
|
|
234
|
+
save(config)
|
|
235
|
+
|
|
236
|
+
console.print()
|
|
237
|
+
_panel(console, theme, "Paste these into Meta",
|
|
238
|
+
"[bold]WhatsApp → Configuration[/bold], the [bold]Edit[/bold] "
|
|
239
|
+
"button beside Webhook:\n\n"
|
|
240
|
+
f" [dim]Callback URL[/dim]\n [bold]{where}[/bold]\n\n"
|
|
241
|
+
f" [dim]Verify token[/dim]\n [bold]{settings.verify_token}"
|
|
242
|
+
"[/bold]\n\n"
|
|
243
|
+
"Save it, then [bold]Manage[/bold] and tick [bold]messages[/bold].")
|
|
244
|
+
|
|
245
|
+
# The endpoint is stood up here so Meta's verification has something to
|
|
246
|
+
# reach: waiting for a callback that nothing is listening for would be a
|
|
247
|
+
# wizard that hangs, blaming the user for its own gap.
|
|
248
|
+
heard: list[str] = []
|
|
249
|
+
endpoint = Endpoint(verify_token=settings.verify_token,
|
|
250
|
+
app_secret=settings.app_secret,
|
|
251
|
+
path=settings.path, host=settings.host,
|
|
252
|
+
port=settings.port,
|
|
253
|
+
announce=lambda line: heard.append(line))
|
|
254
|
+
try:
|
|
255
|
+
endpoint.start()
|
|
256
|
+
except OSError as problem:
|
|
257
|
+
console.print(f"\n [red]Could not listen on {settings.host}:"
|
|
258
|
+
f"{settings.port}: {problem}[/red]")
|
|
259
|
+
console.print(" [dim]Something else may be using that port; change "
|
|
260
|
+
"it with `whatsapp.port` in your config.[/dim]\n")
|
|
261
|
+
if started is not None:
|
|
262
|
+
started.stop()
|
|
263
|
+
return 1
|
|
264
|
+
|
|
265
|
+
console.print()
|
|
266
|
+
console.print(Text(" waiting for Meta to call it back…",
|
|
267
|
+
style=theme.style("dim")))
|
|
268
|
+
|
|
269
|
+
ok = _wait_for_meta(heard)
|
|
270
|
+
endpoint.stop()
|
|
271
|
+
if started is not None:
|
|
272
|
+
started.stop()
|
|
273
|
+
|
|
274
|
+
console.print()
|
|
275
|
+
if not ok:
|
|
276
|
+
console.print(Text(
|
|
277
|
+
" Meta has not called it yet.", style=theme.style("bad")))
|
|
278
|
+
console.print(Text(
|
|
279
|
+
" Everything is saved. `comodor whatsapp webhook` shows the two "
|
|
280
|
+
"values\n again, and `comodor whatsapp start` will answer the "
|
|
281
|
+
"check whenever it comes.",
|
|
282
|
+
style=theme.style("dim")))
|
|
283
|
+
console.print()
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
console.print(Text(f" {theme.glyphs.check} Meta reached the webhook.",
|
|
287
|
+
style=theme.style("good")))
|
|
288
|
+
console.print(Text(
|
|
289
|
+
"\n One thing left — say who may talk to it:",
|
|
290
|
+
style=theme.style("dim")))
|
|
291
|
+
console.print(Text(" comodor whatsapp pair",
|
|
292
|
+
style=theme.style("accent")))
|
|
293
|
+
console.print(Text(
|
|
294
|
+
" then comodor whatsapp start --background",
|
|
295
|
+
style=theme.style("dim")))
|
|
296
|
+
console.print()
|
|
297
|
+
return 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _wait_for_meta(heard: list[str], patience: float | None = None) -> bool:
|
|
301
|
+
"""Whether the verification handshake actually arrived.
|
|
302
|
+
|
|
303
|
+
The default is read here rather than bound as a parameter default: bound,
|
|
304
|
+
it is captured when this module is imported, and nothing that changes
|
|
305
|
+
`VERIFY_PATIENCE` afterwards — a test, a setting — has any effect at all.
|
|
306
|
+
"""
|
|
307
|
+
if patience is None:
|
|
308
|
+
patience = VERIFY_PATIENCE
|
|
309
|
+
deadline = time.time() + patience
|
|
310
|
+
while time.time() < deadline:
|
|
311
|
+
if any("verified by Meta" in line for line in heard):
|
|
312
|
+
return True
|
|
313
|
+
time.sleep(0.5)
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _unfinished(console, config: Config,
|
|
318
|
+
save: Callable[[Config], None]) -> int:
|
|
319
|
+
save(config)
|
|
320
|
+
console.print("\n [dim]Saved what there was. `comodor whatsapp webhook` "
|
|
321
|
+
"when you have an address.[/dim]\n")
|
|
322
|
+
return 0
|