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.
Files changed (225) hide show
  1. {comodor-0.19.0 → comodor-0.19.2}/PKG-INFO +2 -2
  2. {comodor-0.19.0 → comodor-0.19.2}/README.md +1 -1
  3. {comodor-0.19.0 → comodor-0.19.2}/docs/README.md +1 -1
  4. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/_version.py +2 -2
  5. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/help.py +16 -3
  6. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/setup.py +18 -7
  7. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/commands.py +57 -2
  8. comodor-0.19.2/src/comodor/whatsapp/guide.py +322 -0
  9. comodor-0.19.2/src/comodor/whatsapp/tunnel.py +218 -0
  10. {comodor-0.19.0 → comodor-0.19.2}/tests/test_whatsapp.py +140 -0
  11. {comodor-0.19.0 → comodor-0.19.2}/.gitignore +0 -0
  12. {comodor-0.19.0 → comodor-0.19.2}/LICENSE +0 -0
  13. {comodor-0.19.0 → comodor-0.19.2}/pyproject.toml +0 -0
  14. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/__init__.py +0 -0
  15. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/__main__.py +0 -0
  16. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/acp/__init__.py +0 -0
  17. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/acp/agent.py +0 -0
  18. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/acp/commands.py +0 -0
  19. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/acp/jsonrpc.py +0 -0
  20. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/__init__.py +0 -0
  21. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/context.py +0 -0
  22. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/loop.py +0 -0
  23. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/prompts.py +0 -0
  24. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/spawn.py +0 -0
  25. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/agent/tokens.py +0 -0
  26. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/browser/__init__.py +0 -0
  27. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/browser/cdp.py +0 -0
  28. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/browser/launch.py +0 -0
  29. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/browser/page.py +0 -0
  30. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/catalogue.py +0 -0
  31. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/channels/__init__.py +0 -0
  32. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/channels/daemon.py +0 -0
  33. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/channels/unit.py +0 -0
  34. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/cli.py +0 -0
  35. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/config.py +0 -0
  36. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/__init__.py +0 -0
  37. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/guard.py +0 -0
  38. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/keys.py +0 -0
  39. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/overlay.py +0 -0
  40. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/png.py +0 -0
  41. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/screen.py +0 -0
  42. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/desktop/win32.py +0 -0
  43. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/doctor.py +0 -0
  44. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/events.py +0 -0
  45. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/__init__.py +0 -0
  46. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/associations.py +0 -0
  47. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/bm25.py +0 -0
  48. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/hotindex.py +0 -0
  49. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/memory.py +0 -0
  50. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/progress.py +0 -0
  51. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/reflect.py +0 -0
  52. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/rules.py +0 -0
  53. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/signals.py +0 -0
  54. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/store.py +0 -0
  55. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/learning/writer.py +0 -0
  56. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/__init__.py +0 -0
  57. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/catalogue.py +0 -0
  58. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/commands.py +0 -0
  59. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/download.py +0 -0
  60. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/models.json +0 -0
  61. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/provider.py +0 -0
  62. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/runtime.py +0 -0
  63. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/local/store.py +0 -0
  64. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/__init__.py +0 -0
  65. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/catalogue.py +0 -0
  66. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/commands.py +0 -0
  67. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/http.py +0 -0
  68. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/manager.py +0 -0
  69. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/mcp/protocol.py +0 -0
  70. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/migrate.py +0 -0
  71. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/net/__init__.py +0 -0
  72. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/net/http.py +0 -0
  73. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/net/sse.py +0 -0
  74. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/paths.py +0 -0
  75. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/__init__.py +0 -0
  76. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/anthropic.py +0 -0
  77. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/base.py +0 -0
  78. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/caching.py +0 -0
  79. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/discover.py +0 -0
  80. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/fake.py +0 -0
  81. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/gateway.py +0 -0
  82. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/models.py +0 -0
  83. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/oauth.py +0 -0
  84. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/openai_compat.py +0 -0
  85. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/providers/registry.py +0 -0
  86. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/questions.py +0 -0
  87. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/safety/__init__.py +0 -0
  88. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/safety/checkpoints.py +0 -0
  89. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/safety/permissions.py +0 -0
  90. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/safety/redact.py +0 -0
  91. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/session/__init__.py +0 -0
  92. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/session/search.py +0 -0
  93. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/session/store.py +0 -0
  94. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/__init__.py +0 -0
  95. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/catalogue.py +0 -0
  96. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/commands.py +0 -0
  97. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/examples.py +0 -0
  98. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/loader.py +0 -0
  99. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/propose.py +0 -0
  100. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/skills/registry.py +0 -0
  101. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/__init__.py +0 -0
  102. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/api.py +0 -0
  103. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/bot.py +0 -0
  104. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/commands.py +0 -0
  105. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/keyboard.py +0 -0
  106. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/service.py +0 -0
  107. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/telegram/unit.py +0 -0
  108. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/__init__.py +0 -0
  109. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/ask.py +0 -0
  110. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/base.py +0 -0
  111. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/browse.py +0 -0
  112. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/browser.py +0 -0
  113. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/computer.py +0 -0
  114. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/delegate.py +0 -0
  115. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/fs.py +0 -0
  116. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/history.py +0 -0
  117. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/mcp.py +0 -0
  118. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/overflow.py +0 -0
  119. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/registry.py +0 -0
  120. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/search.py +0 -0
  121. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/shell.py +0 -0
  122. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/skills.py +0 -0
  123. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/todo.py +0 -0
  124. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/tools/web.py +0 -0
  125. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/__init__.py +0 -0
  126. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/app.py +0 -0
  127. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/banner.py +0 -0
  128. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/bidi.py +0 -0
  129. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/chooser.py +0 -0
  130. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/clipboard.py +0 -0
  131. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/console.py +0 -0
  132. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/input/__init__.py +0 -0
  133. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/input/keys.py +0 -0
  134. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/input/reader.py +0 -0
  135. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/layout.py +0 -0
  136. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/markdown.py +0 -0
  137. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/screen.py +0 -0
  138. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/theme.py +0 -0
  139. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/__init__.py +0 -0
  140. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/buttons.py +0 -0
  141. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/chat.py +0 -0
  142. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/history.py +0 -0
  143. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/overlay.py +0 -0
  144. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/panel.py +0 -0
  145. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/progress.py +0 -0
  146. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/prompt.py +0 -0
  147. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/questions.py +0 -0
  148. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/statusbar.py +0 -0
  149. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/toast.py +0 -0
  150. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/ui/widgets/welcome.py +0 -0
  151. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/uninstall.py +0 -0
  152. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/update.py +0 -0
  153. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/__init__.py +0 -0
  154. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/commands.py +0 -0
  155. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/index.html +0 -0
  156. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/server.py +0 -0
  157. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/session.py +0 -0
  158. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/ui.css +0 -0
  159. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/ui.js +0 -0
  160. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/vazirmatn-OFL.txt +0 -0
  161. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/web/vazirmatn.woff2 +0 -0
  162. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/__init__.py +0 -0
  163. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/api.py +0 -0
  164. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/bot.py +0 -0
  165. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/menu.py +0 -0
  166. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/service.py +0 -0
  167. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/unit.py +0 -0
  168. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/whatsapp/webhook.py +0 -0
  169. {comodor-0.19.0 → comodor-0.19.2}/src/comodor/workspace.py +0 -0
  170. {comodor-0.19.0 → comodor-0.19.2}/tests/conftest.py +0 -0
  171. {comodor-0.19.0 → comodor-0.19.2}/tests/support/fake_mcp_server.py +0 -0
  172. {comodor-0.19.0 → comodor-0.19.2}/tests/test_acp.py +0 -0
  173. {comodor-0.19.0 → comodor-0.19.2}/tests/test_agent_loop.py +0 -0
  174. {comodor-0.19.0 → comodor-0.19.2}/tests/test_app.py +0 -0
  175. {comodor-0.19.0 → comodor-0.19.2}/tests/test_associations.py +0 -0
  176. {comodor-0.19.0 → comodor-0.19.2}/tests/test_banner.py +0 -0
  177. {comodor-0.19.0 → comodor-0.19.2}/tests/test_bidi.py +0 -0
  178. {comodor-0.19.0 → comodor-0.19.2}/tests/test_browser.py +0 -0
  179. {comodor-0.19.0 → comodor-0.19.2}/tests/test_caching.py +0 -0
  180. {comodor-0.19.0 → comodor-0.19.2}/tests/test_channel_service.py +0 -0
  181. {comodor-0.19.0 → comodor-0.19.2}/tests/test_chooser.py +0 -0
  182. {comodor-0.19.0 → comodor-0.19.2}/tests/test_computer_tool.py +0 -0
  183. {comodor-0.19.0 → comodor-0.19.2}/tests/test_config_save.py +0 -0
  184. {comodor-0.19.0 → comodor-0.19.2}/tests/test_copy_and_bands.py +0 -0
  185. {comodor-0.19.0 → comodor-0.19.2}/tests/test_delegate.py +0 -0
  186. {comodor-0.19.0 → comodor-0.19.2}/tests/test_desktop.py +0 -0
  187. {comodor-0.19.0 → comodor-0.19.2}/tests/test_discover.py +0 -0
  188. {comodor-0.19.0 → comodor-0.19.2}/tests/test_doctor.py +0 -0
  189. {comodor-0.19.0 → comodor-0.19.2}/tests/test_help.py +0 -0
  190. {comodor-0.19.0 → comodor-0.19.2}/tests/test_history.py +0 -0
  191. {comodor-0.19.0 → comodor-0.19.2}/tests/test_import_command.py +0 -0
  192. {comodor-0.19.0 → comodor-0.19.2}/tests/test_input.py +0 -0
  193. {comodor-0.19.0 → comodor-0.19.2}/tests/test_layout.py +0 -0
  194. {comodor-0.19.0 → comodor-0.19.2}/tests/test_learning.py +0 -0
  195. {comodor-0.19.0 → comodor-0.19.2}/tests/test_local.py +0 -0
  196. {comodor-0.19.0 → comodor-0.19.2}/tests/test_markdown.py +0 -0
  197. {comodor-0.19.0 → comodor-0.19.2}/tests/test_mcp.py +0 -0
  198. {comodor-0.19.0 → comodor-0.19.2}/tests/test_mcp_http.py +0 -0
  199. {comodor-0.19.0 → comodor-0.19.2}/tests/test_migrate.py +0 -0
  200. {comodor-0.19.0 → comodor-0.19.2}/tests/test_oauth.py +0 -0
  201. {comodor-0.19.0 → comodor-0.19.2}/tests/test_overflow.py +0 -0
  202. {comodor-0.19.0 → comodor-0.19.2}/tests/test_overlay.py +0 -0
  203. {comodor-0.19.0 → comodor-0.19.2}/tests/test_performance.py +0 -0
  204. {comodor-0.19.0 → comodor-0.19.2}/tests/test_prefix_stability.py +0 -0
  205. {comodor-0.19.0 → comodor-0.19.2}/tests/test_progress.py +0 -0
  206. {comodor-0.19.0 → comodor-0.19.2}/tests/test_propose.py +0 -0
  207. {comodor-0.19.0 → comodor-0.19.2}/tests/test_providers.py +0 -0
  208. {comodor-0.19.0 → comodor-0.19.2}/tests/test_questions.py +0 -0
  209. {comodor-0.19.0 → comodor-0.19.2}/tests/test_real_browser.py +0 -0
  210. {comodor-0.19.0 → comodor-0.19.2}/tests/test_real_desktop.py +0 -0
  211. {comodor-0.19.0 → comodor-0.19.2}/tests/test_real_web_ui.py +0 -0
  212. {comodor-0.19.0 → comodor-0.19.2}/tests/test_reflex.py +0 -0
  213. {comodor-0.19.0 → comodor-0.19.2}/tests/test_run_loop.py +0 -0
  214. {comodor-0.19.0 → comodor-0.19.2}/tests/test_setup.py +0 -0
  215. {comodor-0.19.0 → comodor-0.19.2}/tests/test_setup_import.py +0 -0
  216. {comodor-0.19.0 → comodor-0.19.2}/tests/test_skill_budget.py +0 -0
  217. {comodor-0.19.0 → comodor-0.19.2}/tests/test_skill_catalogue.py +0 -0
  218. {comodor-0.19.0 → comodor-0.19.2}/tests/test_skills.py +0 -0
  219. {comodor-0.19.0 → comodor-0.19.2}/tests/test_switching_model.py +0 -0
  220. {comodor-0.19.0 → comodor-0.19.2}/tests/test_telegram.py +0 -0
  221. {comodor-0.19.0 → comodor-0.19.2}/tests/test_tools.py +0 -0
  222. {comodor-0.19.0 → comodor-0.19.2}/tests/test_uninstall.py +0 -0
  223. {comodor-0.19.0 → comodor-0.19.2}/tests/test_update.py +0 -0
  224. {comodor-0.19.0 → comodor-0.19.2}/tests/test_web.py +0 -0
  225. {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.0
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, the webhook, and Meta's limits |
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, the webhook, and Meta's limits |
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: the webhook, pairing, and Meta's limits |
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.0'
22
- __version_tuple__ = version_tuple = (0, 19, 0)
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 the number, the token, the app secret
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; a tunnel puts a real certificate in front of it:
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 --url http://127.0.0.1:8770
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
- "a bot from @BotFather — one token, set up here in a minute"),
672
+ "recommended — one token from @BotFather, about a minute, "
673
+ "nothing else to set up"),
667
674
  ("whatsapp", "WhatsApp",
668
- "a Meta business number — needs an app at developers.facebook.com")],
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:\n\n"
732
- " [bold]comodor whatsapp connect[/bold]"
733
- "[dim] walks through all four[/dim]\n"
734
- " [bold]comodor whatsapp webhook[/bold]"
735
- "[dim] what to paste into Meta[/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