platform_config.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. """
  2. Chatto Platform Config
  3. """
  4. import sys
  5. import os
  6. from pathlib import Path
  7. # 1. Den absoluten Pfad zum 'vendor'-Ordner in diesem Plugin ermitteln
  8. current_dir = Path(__file__).parent
  9. vendor_dir = current_dir / "vendor"
  10. # 2. Den vendor-Ordner an den Anfang des Suchpfads (sys.path) setzen
  11. if str(vendor_dir) not in sys.path:
  12. sys.path.insert(0, str(vendor_dir))
  13. import logging
  14. from typing import Any, Dict, Generic, Optional, TypeVar
  15. from gateway.config import PlatformConfig
  16. import utils
  17. # Absolute import — the vendor dir is on sys.path (see above) and chattolib's
  18. # own modules import each other absolutely ("from chattolib.x import y").
  19. # Importing it relatively as well would load a *second* copy of every module
  20. # under a different name, so isinstance() checks across the two would fail.
  21. from chattolib.client import ChattoClient
  22. logger = logging.getLogger(__name__)
  23. # --------------------------------------------------------------------------- #
  24. # Constants
  25. # --------------------------------------------------------------------------- #
  26. class ChattoConstants:
  27. """
  28. Chatto Platform Constants
  29. """
  30. def __init__(self):
  31. pass
  32. PLATFORM_NAME: str = "chatto-platform"
  33. PLATFORM_LABEL: str = "Chatto"
  34. INSTALL_HINT = "Requires a Chatto server. See https://docs.chatto.run"
  35. EXTRA_ENV_MAPPING = {
  36. "base_url": "CHATTO_BASE_URL",
  37. "home_channel": "CHATTO_HOME_CHANNEL",
  38. "require_mention": "CHATTO_REQUIRE_MENTION",
  39. "free_response_channels": "CHATTO_FREE_RESPONSE_CHANNELS",
  40. "auto_thread": "CHATTO_AUTO_THREAD",
  41. "allow_all_users": "CHATTO_ALLOW_ALL_USERS",
  42. "allowed_users": "CHATTO_ALLOWED_USERS",
  43. }
  44. MAX_MESSAGE_LENGTH = 10000
  45. SEEN_CAP = 500
  46. # WebSocket / realtime protocol
  47. WS_PATH = "/api/realtime"
  48. WS_AUTH_TIMEOUT = 20.0
  49. WS_MAX_MESSAGE_BYTES = 4_000_000
  50. WS_PING_INTERVAL = 30.0
  51. WS_PING_FAILURE_THRESHOLD = 3
  52. WS_RECONNECT_INITIAL_BACKOFF = 1.0
  53. WS_RECONNECT_MAX_BACKOFF = 30.0
  54. # HTTP timeout used for outbound URL fetches
  55. HTTP_TIMEOUT = 30
  56. # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
  57. # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
  58. EMOJI_TO_SHORTCODE: Dict[str, str] = {
  59. "👍": "thumbsup",
  60. "👎": "thumbsdown",
  61. "❤️": "heart",
  62. "❤": "heart",
  63. "✅": "white_check_mark",
  64. "❌": "x",
  65. "👀": "eyes",
  66. "🎉": "tada",
  67. "😂": "joy",
  68. "🚀": "rocket",
  69. "🔥": "fire",
  70. "💯": "100",
  71. "🤔": "thinking",
  72. "👏": "clap",
  73. "🙏": "pray",
  74. "😅": "sweat_smile",
  75. "😴": "sleeping",
  76. "⏳": "hourglass",
  77. }
  78. # Chunk size for asset uploads (256 KB)
  79. UPLOAD_CHUNK_SIZE = 256 * 1024
  80. def _get_env_or_extra_str_opt(env_var: str, extra_val: Optional[str | bool], default: Optional[str] = None) -> Optional[str]:
  81. """Get a value from environment variable or extra config."""
  82. env_value = os.getenv(env_var)
  83. if env_value is not None:
  84. return env_value.strip()
  85. if extra_val is not None:
  86. if isinstance(extra_val, str):
  87. return extra_val.strip()
  88. elif isinstance(extra_val, bool):
  89. return str(extra_val)
  90. else:
  91. logger.error("extra_val: %s is supposed to be str, but %s was found.", extra_val, str(type(extra_val)))
  92. if default is not None:
  93. logger.debug("Chatto: Defaulting to '%s'", default)
  94. return default.strip()
  95. return None
  96. def _get_env_or_extra_str(env_var: str, extra_val: Optional[str | bool], default: Optional[str] = None) -> str:
  97. """Get a value from environment variable or extra config."""
  98. my_string = _get_env_or_extra_str_opt(env_var, extra_val, default)
  99. if my_string:
  100. return my_string
  101. return ""
  102. def _get_env_or_extra_truthy(env_var: str, extra_val: Optional[str | bool], default: bool = False) -> bool:
  103. """Get a boolean value from environment variable or extra config."""
  104. return utils.is_truthy_value(_get_env_or_extra_str(env_var, extra_val, str(default)), default)
  105. def _split_str_to_list(mystring: str) -> list[str]:
  106. """Split a comma-separated string, trimming whitespace and dropping empties."""
  107. return [part.strip() for part in mystring.split(",") if part.strip()]
  108. def _get_env_or_extra_list(env_var: str, extra_val: Optional[list[str]]) -> list[str]:
  109. """Get a list of values from environment variable or extra config."""
  110. env_value = os.getenv(env_var)
  111. if env_value is not None:
  112. return _split_str_to_list(env_value)
  113. if extra_val is not None:
  114. if isinstance(extra_val, list):
  115. return [str(c).strip() for c in extra_val if str(c).strip()]
  116. if isinstance(extra_val, str):
  117. return _split_str_to_list(extra_val)
  118. return []
  119. T = TypeVar("T")
  120. class ConfigValue(Generic[T]):
  121. """The resolved value of a single config field, bound to one configuration
  122. instance.
  123. Access the payload via ``.value``. ``__bool__``/``__eq__`` delegate to it,
  124. so a forgotten ``.value`` (``if config.allow_all_users:``) still evaluates
  125. the actual setting instead of the always-truthy wrapper object.
  126. """
  127. __slots__ = ("value", "field_name", "env_name")
  128. def __init__(self, value: T, field_name: str, env_name: str) -> None:
  129. self.value = value
  130. self.field_name = field_name
  131. self.env_name = env_name
  132. def __bool__(self) -> bool:
  133. return bool(self.value)
  134. def __eq__(self, other: Any) -> bool:
  135. if isinstance(other, ConfigValue):
  136. return self.value == other.value
  137. return self.value == other
  138. def __hash__(self) -> int:
  139. return hash(self.value)
  140. def __contains__(self, item: Any) -> bool:
  141. return item in self.value # type: ignore[operator]
  142. def __iter__(self):
  143. return iter(self.value) # type: ignore[call-overload]
  144. def __str__(self) -> str:
  145. return str(self.value)
  146. def __repr__(self) -> str:
  147. return f"{self.field_name}={self.value!r}"
  148. class ConfigField(Generic[T]):
  149. """Declarative descriptor for one config field.
  150. Declares *how* a field is read (kind, env var name, default); the resolved
  151. payload lives per configuration instance in ``instance._values``, never on
  152. the descriptor itself. Reading a field on the class (rather than on an
  153. instance) yields the descriptor, so ``ChattoConfiguration.token.env_name``
  154. keeps working for the registration hooks.
  155. """
  156. field_name: str = ""
  157. env_name: str = ""
  158. def __init__(
  159. self,
  160. kind: str,
  161. *,
  162. default: Any = None,
  163. config_key: Optional[str] = None,
  164. doc: str = "",
  165. ) -> None:
  166. # kind: "str" | "str_opt" | "bool" | "list"
  167. self.kind = kind
  168. self.default = default
  169. # config_key: the name used in config.yaml's "extra" block and (upper-cased,
  170. # CHATTO_-prefixed) as the env var, where it differs from the attribute name.
  171. self._config_key = config_key
  172. self.__doc__ = doc
  173. def __set_name__(self, owner: Any, name: str) -> None:
  174. self.field_name = str(name).lower().replace("chatto_", "", 1)
  175. self.config_key = self._config_key or self.field_name
  176. self.env_name = f"CHATTO_{self.config_key.upper()}"
  177. def __get__(self, instance: Any, owner: Any = None) -> "ConfigValue[T]":
  178. if instance is None:
  179. return self # type: ignore[return-value]
  180. return instance._values[self.field_name]
  181. def __set__(self, instance: Any, value: Any) -> None:
  182. raise AttributeError(
  183. f"Chatto: config field '{self.field_name}' is read-only; "
  184. f"set '{self.field_name}.value' if you really need to override it."
  185. )
  186. def resolve(self, extra: Dict[str, Any]) -> ConfigValue:
  187. """Read this field from the environment, then from ``extra``, then the default."""
  188. raw = extra.get(self.config_key)
  189. if self.kind == "list":
  190. value: Any = _get_env_or_extra_list(self.env_name, raw)
  191. elif self.kind == "bool":
  192. value = _get_env_or_extra_truthy(self.env_name, raw, bool(self.default))
  193. elif self.kind == "str_opt":
  194. value = _get_env_or_extra_str_opt(self.env_name, raw, self.default)
  195. else:
  196. value = _get_env_or_extra_str(self.env_name, raw, self.default)
  197. return ConfigValue(value, self.field_name, self.env_name)
  198. class ChattoConfiguration:
  199. """Chatto platform configuration.
  200. Every field is resolved once per instance, in this order:
  201. environment variable → ``PlatformConfig.extra`` → declared default.
  202. """
  203. base_url = ConfigField("str", default=ChattoClient.DEFAULT_BASE_URL)
  204. token = ConfigField("str_opt")
  205. login = ConfigField("str")
  206. password = ConfigField("str")
  207. channels_list = ConfigField("list", config_key="channels")
  208. home_channel = ConfigField("str")
  209. allowed_users = ConfigField("list")
  210. require_mention = ConfigField("bool", default=False)
  211. # free_response_channels: room IDs where the bot responds without being
  212. # mentioned via "@botname" even when require_mention is true.
  213. free_response_channels_list = ConfigField("list", config_key="free_response_channels")
  214. # Auto-thread: by default, Chatto creates a thread for replies to room
  215. # messages (not DMs, not already in a thread). This keeps conversations
  216. # organized in the room. Can be disabled via extra.auto_thread=false.
  217. auto_thread = ConfigField("bool", default=True)
  218. allow_all_users = ConfigField("bool", default=False)
  219. reactions = ConfigField("bool", default=True)
  220. def __init__(self, pconfig: PlatformConfig):
  221. """Resolve every declared ConfigField against env vars and
  222. ``PlatformConfig.extra`` (which Hermes pre-populates from config.yaml).
  223. """
  224. extra: Dict[str, Any] = getattr(pconfig, "extra", None) or {}
  225. self._values: Dict[str, ConfigValue] = {
  226. field.field_name: field.resolve(extra) for field in self.fields()
  227. }
  228. logger.debug("ChattoConfiguration: %s", self)
  229. @classmethod
  230. def fields(cls) -> list[ConfigField]:
  231. """All declared config fields, in declaration order."""
  232. return [v for v in vars(cls).values() if isinstance(v, ConfigField)]
  233. def __str__(self) -> str:
  234. redacted = {"token", "password"}
  235. parts = [
  236. f"{name}={'***' if name in redacted and cv.value else cv.value!r}"
  237. for name, cv in self._values.items()
  238. ]
  239. return "ChattoConfiguration(" + ", ".join(parts) + ")"