platform_config.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. """
  2. Chatto Platform Config
  3. """
  4. import os
  5. import re
  6. # Put the vendored dependencies for THIS platform on sys.path before importing
  7. # anything from chattolib. Imported relatively as part of the plugin package and
  8. # absolutely when this module is loaded standalone (e.g. by the tests).
  9. try:
  10. from .vendor_path import setup_vendor_path
  11. except ImportError: # pragma: no cover - depends on how the module is loaded
  12. from vendor_path import setup_vendor_path
  13. setup_vendor_path()
  14. import logging
  15. from typing import Any, ClassVar, Generic, TypeVar
  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 import ChattoClient
  22. from gateway.config import PlatformConfig
  23. logger = logging.getLogger(__name__)
  24. # --------------------------------------------------------------------------- #
  25. # Constants
  26. # --------------------------------------------------------------------------- #
  27. class ChattoConstants:
  28. """
  29. Chatto Platform Constants
  30. """
  31. def __init__(self):
  32. pass
  33. PLATFORM_NAME: str = "chatto-platform"
  34. PLATFORM_LABEL: str = "Chatto"
  35. INSTALL_HINT = "Requires a Chatto server. See https://docs.chatto.run"
  36. # NOTE: env var names live on the ConfigFields below (ConfigField.env_name),
  37. # so there is exactly one source of truth for them.
  38. MAX_MESSAGE_LENGTH = 10000
  39. # Outgoing text is split below this rather than at MAX_MESSAGE_LENGTH, so
  40. # a full chunk plus the gateway's multi-chunk "(1/2)" label stays under
  41. # the server limit.
  42. SPLIT_THRESHOLD = 9900
  43. SEEN_CAP = 500
  44. # Members fetched per roster announcement (the context block handed to the
  45. # agent when a channel thread opens). Larger rooms get an "and N more"
  46. # note instead of an unbounded prompt block.
  47. ROSTER_MEMBER_LIMIT = 100
  48. # WebSocket reconnect backoff (the realtime transport itself lives in
  49. # chattolib.realtime, which owns protocol-level constants).
  50. WS_RECONNECT_INITIAL_BACKOFF = 1.0
  51. WS_RECONNECT_MAX_BACKOFF = 30.0
  52. # A candidate @-handle, not a confirmed one — every match is resolved
  53. # against the member directory before it counts. Pattern mirrors the Chatto
  54. # web frontend's mention extraction: dots are internal separators only, so
  55. # a sentence-ending "frag @bob." yields the handle "bob", and a leading
  56. # hyphen ("per @-mention") yields nothing to look up. The lookbehind keeps
  57. # the domain half of an e-mail address from becoming a candidate.
  58. MENTION_RE = re.compile(
  59. r"(?<![\w.])@([A-Za-z0-9_](?:[A-Za-z0-9_-]|\.(?=[A-Za-z0-9_-]))*)"
  60. )
  61. # Handles that address the room rather than a person — the bot is one of
  62. # the addressees, so these do not mean "this is for someone else".
  63. # Chatto defines exactly these two virtual handles (FDR-006); anything
  64. # else must resolve to a real user in the directory.
  65. BROADCAST_MENTIONS = frozenset({"all", "here"})
  66. # Presence is a TTL the server lets lapse — chattolib refuses to set OFFLINE
  67. # at all ("stop refreshing to go offline"), so staying online means
  68. # re-announcing ONLINE on this interval for as long as we are connected.
  69. PRESENCE_REFRESH_INTERVAL = 60.0
  70. # HTTP timeout used for outbound URL fetches
  71. HTTP_TIMEOUT = 30
  72. # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
  73. EMOJI_TO_SHORTCODE: ClassVar[dict[str, str]] = {
  74. "👍": "thumbsup",
  75. "👎": "thumbsdown",
  76. "❤️": "heart",
  77. "❤": "heart",
  78. "✅": "white_check_mark",
  79. "❌": "x",
  80. "🚫": "no_entry_sign",
  81. "⛔": "no_entry",
  82. "👀": "eyes",
  83. "🫥": "dotted_line_face",
  84. "🎉": "tada",
  85. "😂": "joy",
  86. "🚀": "rocket",
  87. "🔥": "fire",
  88. "💯": "100",
  89. "🤔": "thinking_face",
  90. "👏": "clap",
  91. "🙏": "pray",
  92. "😅": "sweat_smile",
  93. "😴": "sleeping",
  94. "⏳": "hourglass",
  95. "💭": "thought_balloon",
  96. "👓": "glasses",
  97. "👁️‍🗨️": "eye_in_speech_bubble",
  98. "💬": "speech_balloon",
  99. "🗨️": "left_speech_bubble",
  100. "🗯️": "right_anger_bubble",
  101. "🦾": "mechanical_arm",
  102. "🦿": "mechanical_leg",
  103. }
  104. # Chunk size for asset uploads (256 KB)
  105. UPLOAD_CHUNK_SIZE = 256 * 1024
  106. def _get_env_or_extra_str_opt(
  107. env_var: str, extra_val: str | bool | None, default: str | None = None
  108. ) -> str | None:
  109. """Get a value from environment variable or extra config."""
  110. env_value = os.getenv(env_var)
  111. if env_value is not None:
  112. return env_value.strip()
  113. if extra_val is not None:
  114. if isinstance(extra_val, str):
  115. return extra_val.strip()
  116. elif isinstance(extra_val, bool):
  117. return str(extra_val)
  118. else:
  119. logger.error(
  120. "extra_val: %s is supposed to be str, but %s was found.",
  121. extra_val,
  122. str(type(extra_val)),
  123. )
  124. if default is not None:
  125. logger.debug("Chatto: Defaulting to '%s'", default)
  126. return default.strip()
  127. return None
  128. def _get_env_or_extra_str(
  129. env_var: str, extra_val: str | bool | None, default: str | None = None
  130. ) -> str:
  131. """Get a value from environment variable or extra config."""
  132. my_string = _get_env_or_extra_str_opt(env_var, extra_val, default)
  133. if my_string:
  134. return my_string
  135. return ""
  136. def _get_env_or_extra_truthy(
  137. env_var: str, extra_val: str | bool | None, default: bool = False
  138. ) -> bool:
  139. """Get a boolean value from environment variable or extra config."""
  140. return utils.is_truthy_value(
  141. _get_env_or_extra_str(env_var, extra_val, str(default)), default
  142. )
  143. def _split_str_to_list(mystring: str) -> list[str]:
  144. """Split a comma-separated string, trimming whitespace and dropping empties."""
  145. return [part.strip() for part in mystring.split(",") if part.strip()]
  146. def _get_env_or_extra_list(env_var: str, extra_val: list[str] | None) -> list[str]:
  147. """Get a list of values from environment variable or extra config."""
  148. env_value = os.getenv(env_var)
  149. if env_value is not None:
  150. return _split_str_to_list(env_value)
  151. if extra_val is not None:
  152. if isinstance(extra_val, list):
  153. return [str(c).strip() for c in extra_val if str(c).strip()]
  154. if isinstance(extra_val, str):
  155. return _split_str_to_list(extra_val)
  156. return []
  157. def _get_env_or_extra_int(env_var: str, extra_val: Any, default: int) -> int:
  158. """Get an integer value from environment variable or extra config.
  159. Unparseable values fall back to the default with a warning rather than
  160. raising — a typo in an env var must not keep the plugin from starting.
  161. """
  162. raw = os.getenv(env_var)
  163. if raw is None or not raw.strip():
  164. raw = extra_val
  165. if isinstance(raw, bool): # a YAML true/false is not a number
  166. raw = None
  167. elif isinstance(raw, (int, float)):
  168. return int(raw)
  169. elif isinstance(raw, str) and raw.strip():
  170. try:
  171. return int(raw.strip())
  172. except ValueError:
  173. logger.warning(
  174. "Chatto: %s=%r is not an integer, using default %d",
  175. env_var,
  176. raw,
  177. default,
  178. )
  179. return default
  180. T = TypeVar("T")
  181. class ConfigValue(Generic[T]):
  182. """The resolved value of a single config field, bound to one configuration
  183. instance.
  184. Access the payload via ``.value``. ``__bool__``/``__eq__`` delegate to it,
  185. so a forgotten ``.value`` (``if config.allow_all_users:``) still evaluates
  186. the actual setting instead of the always-truthy wrapper object.
  187. """
  188. __slots__ = ("env_name", "field_name", "value")
  189. def __init__(self, value: T, field_name: str, env_name: str) -> None:
  190. self.value = value
  191. self.field_name = field_name
  192. self.env_name = env_name
  193. def __bool__(self) -> bool:
  194. return bool(self.value)
  195. def __eq__(self, other: object) -> bool:
  196. if isinstance(other, ConfigValue):
  197. return self.value == other.value
  198. return self.value == other
  199. def __hash__(self) -> int:
  200. return hash(self.value)
  201. def __contains__(self, item: Any) -> bool:
  202. return item in self.value # type: ignore[operator]
  203. def __iter__(self):
  204. return iter(self.value) # type: ignore[call-overload]
  205. def __str__(self) -> str:
  206. return str(self.value)
  207. def __repr__(self) -> str:
  208. return f"{self.field_name}={self.value!r}"
  209. class ConfigField(Generic[T]):
  210. """Declarative descriptor for one config field.
  211. Declares *how* a field is read (kind, env var name, default); the resolved
  212. payload lives per configuration instance in ``instance._values``, never on
  213. the descriptor itself. Reading a field on the class (rather than on an
  214. instance) yields the descriptor, so ``ChattoConfiguration.token.env_name``
  215. keeps working for the registration hooks.
  216. """
  217. field_name: str = ""
  218. env_name: str = ""
  219. def __init__(
  220. self,
  221. kind: str,
  222. *,
  223. default: Any = None,
  224. config_key: str | None = None,
  225. doc: str = "",
  226. ) -> None:
  227. # kind: "str" | "str_opt" | "bool" | "list"
  228. self.kind = kind
  229. self.default = default
  230. # config_key: the name used in config.yaml's "extra" block and (upper-cased,
  231. # CHATTO_-prefixed) as the env var, where it differs from the attribute name.
  232. self._config_key = config_key
  233. self.__doc__ = doc
  234. def __set_name__(self, owner: Any, name: str) -> None:
  235. self.field_name = str(name).lower().replace("chatto_", "", 1)
  236. self.config_key = self._config_key or self.field_name
  237. self.env_name = f"CHATTO_{self.config_key.upper()}"
  238. def __get__(self, instance: Any, owner: Any = None) -> "ConfigValue[T]":
  239. if instance is None:
  240. return self # type: ignore[return-value]
  241. return instance._values[self.field_name]
  242. def __set__(self, instance: Any, value: Any) -> None:
  243. raise AttributeError(
  244. f"Chatto: config field '{self.field_name}' is read-only; "
  245. f"set '{self.field_name}.value' if you really need to override it."
  246. )
  247. def resolve(self, extra: dict[str, Any]) -> ConfigValue:
  248. """Read this field from the environment, then from ``extra``, then the default."""
  249. raw = extra.get(self.config_key)
  250. if self.kind == "list":
  251. value: Any = _get_env_or_extra_list(self.env_name, raw)
  252. elif self.kind == "bool":
  253. value = _get_env_or_extra_truthy(self.env_name, raw, bool(self.default))
  254. elif self.kind == "int":
  255. value = _get_env_or_extra_int(self.env_name, raw, int(self.default))
  256. elif self.kind == "str_opt":
  257. value = _get_env_or_extra_str_opt(self.env_name, raw, self.default)
  258. else:
  259. value = _get_env_or_extra_str(self.env_name, raw, self.default)
  260. return ConfigValue(value, self.field_name, self.env_name)
  261. class ChattoConfiguration:
  262. """Chatto platform configuration.
  263. Every field is resolved once per instance, in this order:
  264. environment variable → ``PlatformConfig.extra`` → declared default.
  265. """
  266. base_url = ConfigField("str", default=ChattoClient.DEFAULT_BASE_URL)
  267. token = ConfigField("str_opt")
  268. login = ConfigField("str")
  269. password = ConfigField("str")
  270. home_channel = ConfigField("str")
  271. allowed_users = ConfigField("list")
  272. # Participation in multi-person rooms is opt-in: require_mention_rooms
  273. # lists rooms where the bot answers only when addressed
  274. # (@name/@all/@here), optional_mention_rooms lists rooms where it answers
  275. # everything. A room in neither list stays silent — read-only, never
  276. # seeded into context or answered. Chatto has no group rooms like Signal:
  277. # every non-DM surface is a channel-kind room, so these two lists are the
  278. # only inbound gate; DMs always answer.
  279. require_mention_rooms = ConfigField("list")
  280. optional_mention_rooms = ConfigField("list")
  281. # Auto-thread: by default, Chatto creates a thread for replies to room
  282. # messages (not DMs, not already in a thread). This keeps conversations
  283. # organized in the room. Can be disabled via extra.auto_thread=false.
  284. auto_thread = ConfigField("bool", default=True)
  285. allow_all_users = ConfigField("bool", default=False)
  286. reactions = ConfigField("bool", default=True)
  287. # Inbound edits: a message_edited event re-runs the admission gates against
  288. # the new body. A message currently being processed is cancelled and
  289. # re-dispatched with the corrected text; a message that never passed the
  290. # gates (e.g. a forgotten @mention) gets a fresh turn; an already-answered
  291. # message stays answered.
  292. edit_dispatch = ConfigField("bool", default=True)
  293. # How long after posting an edit may still land, in seconds — edits to
  294. # hours-old messages must not resurrect old conversations. Parsed by
  295. # ConfigField's "int" kind; unparseable input falls back to the default.
  296. edit_window = ConfigField("int", default=300)
  297. def __init__(self, pconfig: PlatformConfig):
  298. """Resolve every declared ConfigField against env vars and
  299. ``PlatformConfig.extra`` (which Hermes pre-populates from config.yaml).
  300. """
  301. extra: dict[str, Any] = getattr(pconfig, "extra", None) or {}
  302. self._values: dict[str, ConfigValue] = {
  303. field.field_name: field.resolve(extra) for field in self.fields()
  304. }
  305. logger.debug("ChattoConfiguration: %s", self)
  306. @classmethod
  307. def fields(cls) -> list[ConfigField]:
  308. """All declared config fields, in declaration order."""
  309. return [v for v in vars(cls).values() if isinstance(v, ConfigField)]
  310. def __str__(self) -> str:
  311. redacted = {"token", "password"}
  312. parts = [
  313. f"{name}={'***' if name in redacted and cv.value else cv.value!r}"
  314. for name, cv in self._values.items()
  315. ]
  316. return "ChattoConfiguration(" + ", ".join(parts) + ")"