platform_config.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. """
  2. Chatto Platform Config
  3. """
  4. from dataclasses import dataclass
  5. import logging
  6. import os
  7. from typing import Any, Dict, Optional, cast
  8. from gateway.config import PlatformConfig
  9. import utils
  10. from vendor.chattolib.client import ChattoClient
  11. from .adapter import ChattoAdapter, hermes_standalone_sender_fn
  12. logger = logging.getLogger(__name__)
  13. # --------------------------------------------------------------------------- #
  14. # Constants
  15. # --------------------------------------------------------------------------- #
  16. class ChattoConstants:
  17. """
  18. Chatto Platform Constants
  19. """
  20. def __init__(self):
  21. pass
  22. PLATFORM_ID: str = "chatto"
  23. PLATFORM_NAME: str = "chatto-platform"
  24. PLATFORM_LABEL: str = "Chatto"
  25. INSTALL_HINT = "Requires a Chatto server. See https://docs.chatto.run"
  26. EXTRA_ENV_MAPPING = {
  27. "base_url": "CHATTO_BASE_URL",
  28. "home_channel": "CHATTO_HOME_CHANNEL",
  29. "require_mention": "CHATTO_REQUIRE_MENTION",
  30. "free_response_channels": "CHATTO_FREE_RESPONSE_CHANNELS",
  31. "auto_thread": "CHATTO_AUTO_THREAD",
  32. "allow_all_users": "CHATTO_ALLOW_ALL_USERS",
  33. "allowed_users": "CHATTO_ALLOWED_USERS",
  34. }
  35. MAX_MESSAGE_LENGTH = 10000
  36. SEEN_CAP = 500
  37. # WebSocket / realtime protocol
  38. WS_PATH = "/api/realtime"
  39. WS_AUTH_TIMEOUT = 20.0
  40. WS_MAX_MESSAGE_BYTES = 4_000_000
  41. WS_PING_INTERVAL = 30.0
  42. WS_RECONNECT_INITIAL_BACKOFF = 1.0
  43. WS_RECONNECT_MAX_BACKOFF = 30.0
  44. # HTTP timeout used for outbound URL fetches
  45. HTTP_TIMEOUT = 30
  46. # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
  47. # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
  48. EMOJI_TO_SHORTCODE: Dict[str, str] = {
  49. "👍": "thumbsup",
  50. "👎": "thumbsdown",
  51. "❤️": "heart",
  52. "❤": "heart",
  53. "✅": "white_check_mark",
  54. "❌": "x",
  55. "👀": "eyes",
  56. "🎉": "tada",
  57. "😂": "joy",
  58. "🚀": "rocket",
  59. "🔥": "fire",
  60. "💯": "100",
  61. "🤔": "thinking",
  62. "👏": "clap",
  63. "🙏": "pray",
  64. "😅": "sweat_smile",
  65. "😴": "sleeping",
  66. "⏳": "hourglass",
  67. }
  68. # Chunk size for asset uploads (256 KB)
  69. UPLOAD_CHUNK_SIZE = 256 * 1024
  70. def _get_env_or_extra_str_opt(env_var: str, extra_val: Optional[str], default: Optional[str] = None) -> Optional[str]:
  71. """Get a value from environment variable or extra config."""
  72. env_value = os.getenv(env_var)
  73. if env_value is not None:
  74. return env_value.strip()
  75. if extra_val is not None:
  76. return extra_val.strip()
  77. if default is not None:
  78. return default.strip()
  79. return None
  80. def _get_env_or_extra_str(env_var: str, extra_val: Optional[str], default: Optional[str] = None) -> str:
  81. """Get a value from environment variable or extra config."""
  82. my_string = _get_env_or_extra_str_opt(env_var, extra_val, default)
  83. if my_string:
  84. return my_string
  85. return ""
  86. def _get_env_or_extra_truthy(env_var: str, extra_val: Optional[str], default: bool = False) -> bool:
  87. """Get a boolean value from environment variable or extra config."""
  88. return utils.is_truthy_value(_get_env_or_extra_str(env_var, extra_val, "False"), default)
  89. def _split_str_to_list(mystring: str) -> list:
  90. return list(c.strip() for c in mystring.split(","))
  91. def _get_env_or_extra_list(env_var: str, extra_val: Optional[set]) -> list[str]:
  92. """Get a list of values from environment variable or extra config."""
  93. env_value = os.getenv(env_var)
  94. if env_value is not None:
  95. return _split_str_to_list(env_value)
  96. if extra_val is not None:
  97. if isinstance(extra_val, list):
  98. return list(
  99. c.strip() for c in extra_val
  100. )
  101. if isinstance(extra_val, str):
  102. return _split_str_to_list(extra_val)
  103. return []
  104. from typing import TypeVar, Generic, Any
  105. # 1. Define a generic Type Variable
  106. T = TypeVar('T')
  107. # 2. Inherit from Generic[T]
  108. class ConfigField(Generic[T]):
  109. """Repräsentiert ein einzelnes Konfigurationsfeld mit IDE-Support."""
  110. # 3. Type 'value' as T (or T | None since it starts as None)
  111. value: T
  112. field_name: str = ""
  113. env_name: str = ""
  114. # 4. Hint that the init argument should match type T
  115. # We add `| Any` as a fallback because complex types like `list[str]`
  116. # can sometimes confuse older type checkers when used with `type[T]`
  117. def __init__(self, type_: type[T] | Any):
  118. self._type = type_
  119. # 5. Ensure the IDE knows only type T can be assigned
  120. def __set__(self, instance: Any, value: T) -> None:
  121. self.value = value
  122. self._type = type(value)
  123. def __str__(self) -> str:
  124. return str(self.value)
  125. def __set_name__(self, owner: Any, name: str) -> None:
  126. temp: str = str(name).lower().replace("chatto_", "", 1)
  127. self.field_name = temp
  128. self.env_name = f"CHATTO_{temp.upper()}"
  129. @dataclass
  130. class ChattoConfiguration:
  131. """
  132. Chatto Platform Config
  133. Some constants and getting from environment/config.yaml
  134. """
  135. base_url = ConfigField(str)
  136. token = ConfigField(str | None)
  137. login = ConfigField(str)
  138. password = ConfigField(str)
  139. channels = ConfigField(str)
  140. channels_list = ConfigField(list[str])
  141. home_channel = ConfigField(str)
  142. allowed_users = ConfigField(str)
  143. require_mention = ConfigField(bool)
  144. free_response_channels_list = ConfigField(list[str])
  145. auto_thread = ConfigField(bool)
  146. allow_all_users = ConfigField(bool)
  147. reactions = ConfigField(bool)
  148. def __init__(self, pconfig: PlatformConfig):
  149. """PlatformConfig from Hermes provides our own configuration within the "extra"
  150. object. But here, we allow overriding via environment variables again.
  151. We take our Configuration from this typed Class, because it is easier access than using extra.get["base_url"].
  152. """
  153. self.base_url.value = _get_env_or_extra_str(self.base_url.env_name,
  154. pconfig.extra.get(self.base_url.field_name), ChattoClient.DEFAULT_BASE_URL)
  155. self.token.value = _get_env_or_extra_str_opt(self.token.env_name, pconfig.extra.get(self.token.field_name))
  156. self.login.value = _get_env_or_extra_str(self.login.env_name, pconfig.extra.get(self.login.field_name))
  157. self.password.value = _get_env_or_extra_str(self.password.env_name, pconfig.extra.get(self.password.field_name))
  158. self.channels.value = str(_get_env_or_extra_list(self.channels.env_name, pconfig.extra.get(self.channels.field_name)))
  159. self.channels_list.value = _get_env_or_extra_list(self.channels.env_name, pconfig.extra.get(self.channels.field_name))
  160. self.home_channel.value = _get_env_or_extra_str(self.home_channel.env_name, pconfig.extra.get(self.home_channel.field_name))
  161. self.allowed_users.value = _get_env_or_extra_str(self.allowed_users.env_name, pconfig.extra.get(self.allowed_users.field_name))
  162. self.require_mention.value = _get_env_or_extra_truthy(self.require_mention.env_name, pconfig.extra.get(self.require_mention.field_name))
  163. # free_response_channels: room IDs where the bot responds without being mentioned via "@botname"
  164. self.free_response_channels_list.value = _get_env_or_extra_list(self.free_response_channels_list.env_name,
  165. pconfig.extra.get(self.free_response_channels_list.field_name))
  166. # Auto-thread: by default, Chatto creates a thread for replies to room
  167. # messages (not DMs, not already in a thread). This keeps conversations
  168. # organized in the room. Can be disabled via extra.auto_thread=false.
  169. self.auto_thread.value = _get_env_or_extra_truthy(self.auto_thread.env_name, pconfig.extra.get(self.auto_thread.field_name))
  170. self.allow_all_users.value = _get_env_or_extra_truthy(self.allow_all_users.env_name, pconfig.extra.get(self.allow_all_users.field_name))
  171. self.reactions.value = _get_env_or_extra_truthy(self.reactions.env_name, pconfig.extra.get(self.reactions.field_name))
  172. def hermes_validate_config_fn(config: PlatformConfig) -> bool:
  173. """"
  174. Function name should be the same as register argument name with "hermes_" prefix, so we
  175. know that it is needed for plugin register(). Do not change signature.
  176. - config
  177. Check whether Chatto Plugin is configured."""
  178. chatto_config = ChattoConfiguration(pconfig=config)
  179. if chatto_config.base_url.value and (chatto_config.token.value or (chatto_config.login.value and chatto_config.password.value)):
  180. return True
  181. return False
  182. def hermes_check_fn() -> bool:
  183. """Check if Chatto is configured and dependencies are available.
  184. Add real logic?! Or just .. there are no dependencies.. always return true. Really. Docs suck."""
  185. try:
  186. from vendor.chattolib.client import ChattoClient
  187. return True
  188. except ImportError:
  189. return False
  190. return True
  191. # ---------------------------------------------------------------------------
  192. # is_connected probe
  193. # ---------------------------------------------------------------------------
  194. def hermes_is_connected(config: PlatformConfig) -> bool:
  195. """Check whether Chatto Plugin is connected. But where to? To the Hermes Agent? To Chatto Server?
  196. The Hermes Agent plugin docs suck and it seems there are many functions to do the same."""
  197. return bool(hermes_validate_config_fn(config) and config.enabled)
  198. # ---------------------------------------------------------------------------
  199. # YAML → env config bridge
  200. # ---------------------------------------------------------------------------
  201. @DeprecationWarning
  202. def hermes_apply_yaml_config_fn(yaml_dict: dict, platform_dict: dict) -> Optional[dict]:
  203. """Translate config.yaml chatto.extra keys into CHATTO_* env vars.
  204. I don't actually get why Hermes wants us to modify OS environment variables.
  205. Bad behavior in my book.
  206. Also .. I don't think we need this"""
  207. if not isinstance(platform_dict, dict):
  208. platform_dict = {}
  209. extra = platform_dict.get("extra", {}) or {}
  210. if not isinstance(extra, dict):
  211. extra = {}
  212. for yaml_key, env_key in ChattoConstants.EXTRA_ENV_MAPPING.items():
  213. val = extra.get(yaml_key)
  214. if val is not None and not os.getenv(env_key):
  215. if isinstance(val, bool):
  216. env_val = str(val).lower()
  217. elif isinstance(val, list):
  218. env_val = ",".join(str(v) for v in val)
  219. else:
  220. env_val = str(val)
  221. os.environ[env_key] = env_val
  222. channels = extra.get(ChattoConfiguration.channels.field_name)
  223. if isinstance(channels, list) and not os.getenv(ChattoConfiguration.channels.env_name):
  224. os.environ[ChattoConfiguration.channels.env_name] = ",".join(str(c) for c in channels)
  225. allowed = extra.get(ChattoConfiguration.allowed_users.field_name)
  226. if isinstance(allowed, list) and not os.getenv(ChattoConfiguration.allowed_users.env_name):
  227. os.environ[ChattoConfiguration.allowed_users.env_name] = ",".join(str(u) for u in allowed)
  228. if ChattoConfiguration.allow_all_users.field_name in extra and not os.getenv(ChattoConfiguration.allow_all_users.env_name):
  229. os.environ[ChattoConfiguration.allow_all_users.env_name] = str(extra[ChattoConfiguration.allow_all_users.field_name]).lower()
  230. return None
  231. def hermes_setup_fn() -> None:
  232. """Interactive setup wizard for Chatto. Is called by and only works in Hermes CLI context.
  233. Function name should be the same as register argument name with "hermes_" prefix, so we
  234. know that it is needed for plugin register().
  235. """
  236. from hermes_cli.setup import (
  237. prompt,
  238. prompt_yes_no,
  239. save_env_value,
  240. get_env_value,
  241. print_header,
  242. print_info,
  243. print_warning,
  244. print_success,
  245. )
  246. url = prompt(
  247. "Chatto server URL (e.g. https://chat.example.com) or leave blank for default ChattoHQ on chat.chatto.run:")
  248. if url:
  249. save_env_value(ChattoConfiguration.base_url.env_name, url)
  250. login = prompt("Chatto login (username):")
  251. if login:
  252. save_env_value(ChattoConfiguration.login.env_name, login)
  253. password = prompt("Chatto password:", password=True)
  254. if password:
  255. save_env_value(ChattoConfiguration.password.env_name, password)
  256. channels = prompt("Room IDs to watch (comma-separated, or empty for all):")
  257. if channels:
  258. save_env_value(ChattoConfiguration.channels.env_name, channels)
  259. home = prompt("Home room ID for notifications (or empty):")
  260. if home:
  261. save_env_value(ChattoConfiguration.home_channel.env_name, home)
  262. allow_all = prompt_yes_no("Allow all users to talk? (true/false):")
  263. if allow_all:
  264. save_env_value(ChattoConfiguration.allow_all_users.env_name, str(allow_all))
  265. print_success("\n✓ Chatto configured. Restart the gateway to activate.")
  266. def hermes_env_enablement_fn() -> Optional[dict]:
  267. """Seed PlatformConfig.extra from env vars.
  268. Returns a dict compatible with the PlatformConfig merge hook (or None
  269. when no env-provided values are present).
  270. Called by the platform registry during load_gateway_config().
  271. Return None when the platform isn't minimally configured — the
  272. caller then skips auto-enabling. Return a dict to seed extras.
  273. The special 'home_channel' key is extracted and becomes a proper
  274. HomeChannel dataclass on the PlatformConfig; every other key is
  275. merged into PlatformConfig.extra.
  276. Function name should be the same as register argument name with "hermes_" prefix, so we
  277. know that it is needed for plugin register().
  278. """
  279. def _add_env_to_seed(seed: dict, our_key: str) -> dict:
  280. env_value = os.getenv(our_key.upper())
  281. if env_value:
  282. seed[our_key.lower()] = env_value
  283. return seed
  284. seed = {}
  285. seed["base_url"] = (os.getenv(ChattoConfiguration.base_url.env_name) or ChattoClient.DEFAULT_BASE_URL).strip()
  286. seed = _add_env_to_seed(seed, ChattoConfiguration.token.env_name)
  287. seed = _add_env_to_seed(seed, ChattoConfiguration.login.env_name)
  288. seed = _add_env_to_seed(seed, ChattoConfiguration.password.env_name)
  289. seed = _add_env_to_seed(seed, ChattoConfiguration.channels.env_name)
  290. seed = _add_env_to_seed(seed, ChattoConfiguration.home_channel.env_name)
  291. seed = _add_env_to_seed(seed, ChattoConfiguration.require_mention.env_name)
  292. seed = _add_env_to_seed(seed, ChattoConfiguration.free_response_channels_list.env_name)
  293. seed = _add_env_to_seed(seed, ChattoConfiguration.auto_thread.env_name)
  294. return seed
  295. # ---------------------------------------------------------------------------
  296. # Plugin registration entry point
  297. # ---------------------------------------------------------------------------
  298. def hermes_adapter_factory(config: PlatformConfig):
  299. """Factory wrapper that constructs ChattoAdapter from a PlatformConfig."""
  300. return ChattoAdapter(config)
  301. def register(ctx) -> None:
  302. """Plugin entry point — called by the Hermes plugin system."""
  303. logger.error("Registering Chatto Plugin")
  304. ctx.register_platform(
  305. name=ChattoConstants.PLATFORM_NAME,
  306. label=ChattoConstants.PLATFORM_LABEL,
  307. adapter_factory=hermes_adapter_factory,
  308. check_fn=hermes_check_fn,
  309. validate_config=hermes_validate_config_fn,
  310. is_connected=hermes_is_connected,
  311. install_hint=ChattoConstants.INSTALL_HINT,
  312. env_enablement_fn=hermes_env_enablement_fn,
  313. setup_fn=hermes_setup_fn,
  314. apply_yaml_config_fn=hermes_apply_yaml_config_fn,
  315. cron_deliver_env_var=ChattoConfiguration.home_channel.env_name,
  316. standalone_sender_fn=hermes_standalone_sender_fn,
  317. allowed_users_env=ChattoConfiguration.allowed_users.env_name,
  318. allow_all_env=ChattoConfiguration.allow_all_users.env_name,
  319. max_message_length=ChattoConstants.MAX_MESSAGE_LENGTH,
  320. emoji="💬",
  321. allow_update_command=True,
  322. pii_safe=False,
  323. platform_hint=(
  324. "You are chatting in Chatto (a self-hosted or cloud-hosted team or community chat server). "
  325. "Markdown is supported. Users _may_ address you by @-mentioning your name. If configured, " \
  326. "you also react without a @-mention. Direct messages reach you without a mention."
  327. "Keep responses conversational."
  328. ),
  329. )