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.
Files changed (235) hide show
  1. {comodor-0.19.2 → comodor-0.20.0}/PKG-INFO +3 -2
  2. {comodor-0.19.2 → comodor-0.20.0}/README.md +2 -1
  3. {comodor-0.19.2 → comodor-0.20.0}/docs/README.md +1 -0
  4. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/_version.py +2 -2
  5. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/browser/cdp.py +21 -123
  6. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/channels/__init__.py +17 -1
  7. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/cli.py +6 -0
  8. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/config.py +56 -5
  9. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/doctor.py +7 -2
  10. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/help.py +28 -0
  11. comodor-0.20.0/src/comodor/net/ws.py +210 -0
  12. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/setup.py +32 -1
  13. comodor-0.20.0/src/comodor/slack/__init__.py +7 -0
  14. comodor-0.20.0/src/comodor/slack/api.py +237 -0
  15. comodor-0.20.0/src/comodor/slack/blocks.py +221 -0
  16. comodor-0.20.0/src/comodor/slack/bot.py +742 -0
  17. comodor-0.20.0/src/comodor/slack/commands.py +521 -0
  18. comodor-0.20.0/src/comodor/slack/service.py +33 -0
  19. comodor-0.20.0/src/comodor/slack/socket.py +202 -0
  20. comodor-0.20.0/src/comodor/slack/unit.py +24 -0
  21. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/server.py +26 -0
  22. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/session.py +266 -0
  23. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/ui.js +216 -36
  24. comodor-0.20.0/tests/test_slack.py +488 -0
  25. {comodor-0.19.2 → comodor-0.20.0}/tests/test_web.py +107 -0
  26. {comodor-0.19.2 → comodor-0.20.0}/.gitignore +0 -0
  27. {comodor-0.19.2 → comodor-0.20.0}/LICENSE +0 -0
  28. {comodor-0.19.2 → comodor-0.20.0}/pyproject.toml +0 -0
  29. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/__init__.py +0 -0
  30. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/__main__.py +0 -0
  31. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/acp/__init__.py +0 -0
  32. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/acp/agent.py +0 -0
  33. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/acp/commands.py +0 -0
  34. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/acp/jsonrpc.py +0 -0
  35. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/__init__.py +0 -0
  36. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/context.py +0 -0
  37. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/loop.py +0 -0
  38. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/prompts.py +0 -0
  39. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/spawn.py +0 -0
  40. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/agent/tokens.py +0 -0
  41. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/browser/__init__.py +0 -0
  42. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/browser/launch.py +0 -0
  43. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/browser/page.py +0 -0
  44. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/catalogue.py +0 -0
  45. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/channels/daemon.py +0 -0
  46. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/channels/unit.py +0 -0
  47. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/__init__.py +0 -0
  48. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/guard.py +0 -0
  49. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/keys.py +0 -0
  50. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/overlay.py +0 -0
  51. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/png.py +0 -0
  52. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/screen.py +0 -0
  53. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/desktop/win32.py +0 -0
  54. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/events.py +0 -0
  55. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/__init__.py +0 -0
  56. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/associations.py +0 -0
  57. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/bm25.py +0 -0
  58. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/hotindex.py +0 -0
  59. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/memory.py +0 -0
  60. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/progress.py +0 -0
  61. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/reflect.py +0 -0
  62. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/rules.py +0 -0
  63. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/signals.py +0 -0
  64. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/store.py +0 -0
  65. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/learning/writer.py +0 -0
  66. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/__init__.py +0 -0
  67. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/catalogue.py +0 -0
  68. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/commands.py +0 -0
  69. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/download.py +0 -0
  70. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/models.json +0 -0
  71. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/provider.py +0 -0
  72. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/runtime.py +0 -0
  73. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/local/store.py +0 -0
  74. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/__init__.py +0 -0
  75. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/catalogue.py +0 -0
  76. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/commands.py +0 -0
  77. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/http.py +0 -0
  78. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/manager.py +0 -0
  79. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/mcp/protocol.py +0 -0
  80. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/migrate.py +0 -0
  81. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/net/__init__.py +0 -0
  82. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/net/http.py +0 -0
  83. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/net/sse.py +0 -0
  84. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/paths.py +0 -0
  85. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/__init__.py +0 -0
  86. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/anthropic.py +0 -0
  87. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/base.py +0 -0
  88. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/caching.py +0 -0
  89. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/discover.py +0 -0
  90. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/fake.py +0 -0
  91. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/gateway.py +0 -0
  92. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/models.py +0 -0
  93. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/oauth.py +0 -0
  94. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/openai_compat.py +0 -0
  95. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/providers/registry.py +0 -0
  96. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/questions.py +0 -0
  97. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/safety/__init__.py +0 -0
  98. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/safety/checkpoints.py +0 -0
  99. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/safety/permissions.py +0 -0
  100. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/safety/redact.py +0 -0
  101. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/session/__init__.py +0 -0
  102. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/session/search.py +0 -0
  103. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/session/store.py +0 -0
  104. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/__init__.py +0 -0
  105. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/catalogue.py +0 -0
  106. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/commands.py +0 -0
  107. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/examples.py +0 -0
  108. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/loader.py +0 -0
  109. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/propose.py +0 -0
  110. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/skills/registry.py +0 -0
  111. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/__init__.py +0 -0
  112. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/api.py +0 -0
  113. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/bot.py +0 -0
  114. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/commands.py +0 -0
  115. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/keyboard.py +0 -0
  116. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/service.py +0 -0
  117. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/telegram/unit.py +0 -0
  118. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/__init__.py +0 -0
  119. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/ask.py +0 -0
  120. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/base.py +0 -0
  121. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/browse.py +0 -0
  122. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/browser.py +0 -0
  123. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/computer.py +0 -0
  124. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/delegate.py +0 -0
  125. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/fs.py +0 -0
  126. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/history.py +0 -0
  127. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/mcp.py +0 -0
  128. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/overflow.py +0 -0
  129. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/registry.py +0 -0
  130. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/search.py +0 -0
  131. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/shell.py +0 -0
  132. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/skills.py +0 -0
  133. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/todo.py +0 -0
  134. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/tools/web.py +0 -0
  135. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/__init__.py +0 -0
  136. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/app.py +0 -0
  137. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/banner.py +0 -0
  138. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/bidi.py +0 -0
  139. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/chooser.py +0 -0
  140. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/clipboard.py +0 -0
  141. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/console.py +0 -0
  142. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/input/__init__.py +0 -0
  143. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/input/keys.py +0 -0
  144. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/input/reader.py +0 -0
  145. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/layout.py +0 -0
  146. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/markdown.py +0 -0
  147. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/screen.py +0 -0
  148. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/theme.py +0 -0
  149. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/__init__.py +0 -0
  150. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/buttons.py +0 -0
  151. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/chat.py +0 -0
  152. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/history.py +0 -0
  153. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/overlay.py +0 -0
  154. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/panel.py +0 -0
  155. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/progress.py +0 -0
  156. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/prompt.py +0 -0
  157. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/questions.py +0 -0
  158. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/statusbar.py +0 -0
  159. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/toast.py +0 -0
  160. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/ui/widgets/welcome.py +0 -0
  161. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/uninstall.py +0 -0
  162. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/update.py +0 -0
  163. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/__init__.py +0 -0
  164. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/commands.py +0 -0
  165. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/index.html +0 -0
  166. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/ui.css +0 -0
  167. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/vazirmatn-OFL.txt +0 -0
  168. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/web/vazirmatn.woff2 +0 -0
  169. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/__init__.py +0 -0
  170. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/api.py +0 -0
  171. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/bot.py +0 -0
  172. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/commands.py +0 -0
  173. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/guide.py +0 -0
  174. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/menu.py +0 -0
  175. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/service.py +0 -0
  176. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/tunnel.py +0 -0
  177. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/unit.py +0 -0
  178. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/whatsapp/webhook.py +0 -0
  179. {comodor-0.19.2 → comodor-0.20.0}/src/comodor/workspace.py +0 -0
  180. {comodor-0.19.2 → comodor-0.20.0}/tests/conftest.py +0 -0
  181. {comodor-0.19.2 → comodor-0.20.0}/tests/support/fake_mcp_server.py +0 -0
  182. {comodor-0.19.2 → comodor-0.20.0}/tests/test_acp.py +0 -0
  183. {comodor-0.19.2 → comodor-0.20.0}/tests/test_agent_loop.py +0 -0
  184. {comodor-0.19.2 → comodor-0.20.0}/tests/test_app.py +0 -0
  185. {comodor-0.19.2 → comodor-0.20.0}/tests/test_associations.py +0 -0
  186. {comodor-0.19.2 → comodor-0.20.0}/tests/test_banner.py +0 -0
  187. {comodor-0.19.2 → comodor-0.20.0}/tests/test_bidi.py +0 -0
  188. {comodor-0.19.2 → comodor-0.20.0}/tests/test_browser.py +0 -0
  189. {comodor-0.19.2 → comodor-0.20.0}/tests/test_caching.py +0 -0
  190. {comodor-0.19.2 → comodor-0.20.0}/tests/test_channel_service.py +0 -0
  191. {comodor-0.19.2 → comodor-0.20.0}/tests/test_chooser.py +0 -0
  192. {comodor-0.19.2 → comodor-0.20.0}/tests/test_computer_tool.py +0 -0
  193. {comodor-0.19.2 → comodor-0.20.0}/tests/test_config_save.py +0 -0
  194. {comodor-0.19.2 → comodor-0.20.0}/tests/test_copy_and_bands.py +0 -0
  195. {comodor-0.19.2 → comodor-0.20.0}/tests/test_delegate.py +0 -0
  196. {comodor-0.19.2 → comodor-0.20.0}/tests/test_desktop.py +0 -0
  197. {comodor-0.19.2 → comodor-0.20.0}/tests/test_discover.py +0 -0
  198. {comodor-0.19.2 → comodor-0.20.0}/tests/test_doctor.py +0 -0
  199. {comodor-0.19.2 → comodor-0.20.0}/tests/test_help.py +0 -0
  200. {comodor-0.19.2 → comodor-0.20.0}/tests/test_history.py +0 -0
  201. {comodor-0.19.2 → comodor-0.20.0}/tests/test_import_command.py +0 -0
  202. {comodor-0.19.2 → comodor-0.20.0}/tests/test_input.py +0 -0
  203. {comodor-0.19.2 → comodor-0.20.0}/tests/test_layout.py +0 -0
  204. {comodor-0.19.2 → comodor-0.20.0}/tests/test_learning.py +0 -0
  205. {comodor-0.19.2 → comodor-0.20.0}/tests/test_local.py +0 -0
  206. {comodor-0.19.2 → comodor-0.20.0}/tests/test_markdown.py +0 -0
  207. {comodor-0.19.2 → comodor-0.20.0}/tests/test_mcp.py +0 -0
  208. {comodor-0.19.2 → comodor-0.20.0}/tests/test_mcp_http.py +0 -0
  209. {comodor-0.19.2 → comodor-0.20.0}/tests/test_migrate.py +0 -0
  210. {comodor-0.19.2 → comodor-0.20.0}/tests/test_oauth.py +0 -0
  211. {comodor-0.19.2 → comodor-0.20.0}/tests/test_overflow.py +0 -0
  212. {comodor-0.19.2 → comodor-0.20.0}/tests/test_overlay.py +0 -0
  213. {comodor-0.19.2 → comodor-0.20.0}/tests/test_performance.py +0 -0
  214. {comodor-0.19.2 → comodor-0.20.0}/tests/test_prefix_stability.py +0 -0
  215. {comodor-0.19.2 → comodor-0.20.0}/tests/test_progress.py +0 -0
  216. {comodor-0.19.2 → comodor-0.20.0}/tests/test_propose.py +0 -0
  217. {comodor-0.19.2 → comodor-0.20.0}/tests/test_providers.py +0 -0
  218. {comodor-0.19.2 → comodor-0.20.0}/tests/test_questions.py +0 -0
  219. {comodor-0.19.2 → comodor-0.20.0}/tests/test_real_browser.py +0 -0
  220. {comodor-0.19.2 → comodor-0.20.0}/tests/test_real_desktop.py +0 -0
  221. {comodor-0.19.2 → comodor-0.20.0}/tests/test_real_web_ui.py +0 -0
  222. {comodor-0.19.2 → comodor-0.20.0}/tests/test_reflex.py +0 -0
  223. {comodor-0.19.2 → comodor-0.20.0}/tests/test_run_loop.py +0 -0
  224. {comodor-0.19.2 → comodor-0.20.0}/tests/test_setup.py +0 -0
  225. {comodor-0.19.2 → comodor-0.20.0}/tests/test_setup_import.py +0 -0
  226. {comodor-0.19.2 → comodor-0.20.0}/tests/test_skill_budget.py +0 -0
  227. {comodor-0.19.2 → comodor-0.20.0}/tests/test_skill_catalogue.py +0 -0
  228. {comodor-0.19.2 → comodor-0.20.0}/tests/test_skills.py +0 -0
  229. {comodor-0.19.2 → comodor-0.20.0}/tests/test_switching_model.py +0 -0
  230. {comodor-0.19.2 → comodor-0.20.0}/tests/test_telegram.py +0 -0
  231. {comodor-0.19.2 → comodor-0.20.0}/tests/test_tools.py +0 -0
  232. {comodor-0.19.2 → comodor-0.20.0}/tests/test_uninstall.py +0 -0
  233. {comodor-0.19.2 → comodor-0.20.0}/tests/test_update.py +0 -0
  234. {comodor-0.19.2 → comodor-0.20.0}/tests/test_welcome.py +0 -0
  235. {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.19.2
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, 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/whatsapp.md) |
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, 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/whatsapp.md) |
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.19.2'
22
- __version_tuple__ = version_tuple = (0, 19, 2)
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
- """RFC 6455, client side, and only the parts CDP uses.
52
+ class WebSocket(_WebSocket):
53
+ """The general client, with the browser's vocabulary on its failures.
55
54
 
56
- No extensions, no compression, no continuation frames on send: CDP sends
57
- JSON text and so do we. Fragmented *incoming* frames are reassembled,
58
- because a large accessibility tree arrives in pieces.
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
- self._socket = socket.create_connection((host, int(port or 80)),
67
- timeout=timeout)
68
- except OSError as error:
69
- raise BrowserError(f"could not connect to the browser: {error}") from error
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
- payload = text.encode()
99
- frame = bytearray([0x81]) # FIN + text
100
- length = len(payload)
101
- if length < 126:
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
- self._socket.close()
180
- except OSError:
181
- pass
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
- CHANNELS = (TELEGRAM, WHATSAPP)
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 `whatsapp` are deliberately absent,
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 people who may drive
825
- # that machine from a phone — and unlike the other two, nothing on screen
826
- # would ever show it happening. None of the four belongs to a repository.
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