platform_config.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  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 collections.abc import Mapping
  16. from typing import Any, ClassVar, Generic, TypeVar
  17. import utils
  18. # Absolute import — the vendor dir is on sys.path (see above) and chattolib's
  19. # own modules import each other absolutely ("from chattolib.x import y").
  20. # Importing it relatively as well would load a *second* copy of every module
  21. # under a different name, so isinstance() checks across the two would fail.
  22. from chattolib import ChattoClient
  23. from gateway.config import PlatformConfig
  24. logger = logging.getLogger(__name__)
  25. # --------------------------------------------------------------------------- #
  26. # Constants
  27. # --------------------------------------------------------------------------- #
  28. class ChattoConstants:
  29. """
  30. Chatto Platform Constants
  31. """
  32. def __init__(self):
  33. pass
  34. PLATFORM_NAME: str = "chatto-platform"
  35. PLATFORM_LABEL: str = "Chatto"
  36. INSTALL_HINT = "Requires a Chatto server. See https://docs.chatto.run"
  37. # NOTE: env var names live on the ConfigFields below (ConfigField.env_name),
  38. # so there is exactly one source of truth for them.
  39. MAX_MESSAGE_LENGTH = 10000
  40. # Outgoing text is split below this rather than at MAX_MESSAGE_LENGTH, so
  41. # a full chunk plus the gateway's multi-chunk "(1/2)" label stays under
  42. # the server limit.
  43. SPLIT_THRESHOLD = 9900
  44. SEEN_CAP = 500
  45. # Members fetched per roster delivery (the context block handed to the
  46. # agent alongside every channel turn). The roster rides on each message,
  47. # so the cap keeps the prompt block small; larger rooms get an "and N
  48. # more" note instead.
  49. ROSTER_MEMBER_LIMIT = 20
  50. # Thread messages handed to the agent on the FIRST turn of a thread (see
  51. # ChattoAdapter._thread_context_for). A thread session starts empty, so
  52. # without this block a reply that roots a thread is answered without the
  53. # thread it replies to. Both caps bound the one-shot block: the message
  54. # count bounds the fetch, the character count bounds what reaches the
  55. # prompt — the oldest messages are dropped first when the second bites.
  56. THREAD_CONTEXT_MESSAGE_LIMIT = 20
  57. THREAD_CONTEXT_CHAR_LIMIT = 2000
  58. # WebSocket reconnect backoff (the realtime transport itself lives in
  59. # chattolib.realtime, which owns protocol-level constants).
  60. WS_RECONNECT_INITIAL_BACKOFF = 1.0
  61. WS_RECONNECT_MAX_BACKOFF = 30.0
  62. # A candidate @-handle, not a confirmed one — every match is resolved
  63. # against the member directory before it counts. Pattern mirrors the Chatto
  64. # web frontend's mention extraction: dots are internal separators only, so
  65. # a sentence-ending "frag @bob." yields the handle "bob", and a leading
  66. # hyphen ("per @-mention") yields nothing to look up. The lookbehind keeps
  67. # the domain half of an e-mail address from becoming a candidate.
  68. MENTION_RE = re.compile(
  69. r"(?<![\w.])@([A-Za-z0-9_](?:[A-Za-z0-9_-]|\.(?=[A-Za-z0-9_-]))*)"
  70. )
  71. # Handles that address the room rather than a person — the bot is one of
  72. # the addressees, so these do not mean "this is for someone else".
  73. # Chatto defines exactly these two virtual handles (FDR-006); anything
  74. # else must resolve to a real user in the directory.
  75. BROADCAST_MENTIONS = frozenset({"all", "here"})
  76. # Presence is a TTL the server lets lapse — chattolib refuses to set OFFLINE
  77. # at all ("stop refreshing to go offline"), so staying online means
  78. # re-announcing ONLINE on this interval for as long as we are connected.
  79. PRESENCE_REFRESH_INTERVAL = 60.0
  80. # HTTP timeout used for outbound URL fetches
  81. HTTP_TIMEOUT = 30
  82. # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
  83. EMOJI_TO_SHORTCODE: ClassVar[dict[str, str]] = {
  84. "👍": "thumbsup",
  85. "👎": "thumbsdown",
  86. "❤️": "heart",
  87. "❤": "heart",
  88. "✅": "white_check_mark",
  89. "❌": "x",
  90. "🚫": "no_entry_sign",
  91. "⛔": "no_entry",
  92. "👀": "eyes",
  93. "🫥": "dotted_line_face",
  94. "🎉": "tada",
  95. "😂": "joy",
  96. "🚀": "rocket",
  97. "🔥": "fire",
  98. "💯": "100",
  99. "🤔": "thinking_face",
  100. "👏": "clap",
  101. "🙏": "pray",
  102. "😅": "sweat_smile",
  103. "😴": "sleeping",
  104. "⏳": "hourglass",
  105. "💭": "thought_balloon",
  106. "👓": "glasses",
  107. "👁️‍🗨️": "eye_in_speech_bubble",
  108. "💬": "speech_balloon",
  109. "🗨️": "left_speech_bubble",
  110. "🗯️": "right_anger_bubble",
  111. "🦾": "mechanical_arm",
  112. "🦿": "mechanical_leg",
  113. }
  114. # Chunk size for asset uploads (256 KB)
  115. UPLOAD_CHUNK_SIZE = 256 * 1024
  116. def _parse_str_opt(raw: str | bool | None, default: str | None = None) -> str | None:
  117. """Parse one raw value into a plain string, or None when there is none.
  118. Each kind is parsed by a ``_parse_*`` function; the ``_get_env_or_extra_*``
  119. wrappers below decide *where* a value comes from, these decide what it
  120. means — so a value read from one source cannot parse differently than the
  121. same value read from another.
  122. """
  123. if raw is not None:
  124. if isinstance(raw, str):
  125. return raw.strip()
  126. elif isinstance(raw, bool):
  127. return str(raw)
  128. else:
  129. logger.error(
  130. "raw value: %s is supposed to be str, but %s was found.",
  131. raw,
  132. str(type(raw)),
  133. )
  134. if default is not None:
  135. logger.debug("Chatto: Defaulting to '%s'", default)
  136. return default.strip()
  137. return None
  138. def _parse_str(raw: str | bool | None, default: str | None = None) -> str:
  139. """Parse one raw value into a plain string; an absent value becomes ""."""
  140. value = _parse_str_opt(raw, default)
  141. return value if value else ""
  142. def _parse_truthy(raw: str | bool | None, default: bool = False) -> bool:
  143. """Parse one raw value into a boolean, ``default`` standing in for absent."""
  144. return utils.is_truthy_value(_parse_str(raw, str(default)), default)
  145. def _split_str_to_list(mystring: str) -> list[str]:
  146. """Split a comma-separated string, trimming whitespace and dropping empties."""
  147. return [part.strip() for part in mystring.split(",") if part.strip()]
  148. def _parse_list(raw: list[str] | str | None) -> list[str]:
  149. """Parse one raw value into a list, from a list or a comma-separated string."""
  150. if isinstance(raw, list):
  151. return [str(c).strip() for c in raw if str(c).strip()]
  152. if isinstance(raw, str):
  153. return _split_str_to_list(raw)
  154. return []
  155. def _parse_int(raw: Any, env_name: str, default: int) -> int:
  156. """Parse one raw value into an integer.
  157. Unparseable values fall back to the default with a warning rather than
  158. raising — a typo in an env var must not keep the plugin from starting.
  159. """
  160. if isinstance(raw, bool): # a YAML true/false is not a number
  161. raw = None
  162. elif isinstance(raw, (int, float)):
  163. return int(raw)
  164. elif isinstance(raw, str) and raw.strip():
  165. try:
  166. return int(raw.strip())
  167. except ValueError:
  168. logger.warning(
  169. "Chatto: %s=%r is not an integer, using default %d",
  170. env_name,
  171. raw,
  172. default,
  173. )
  174. return default
  175. def _get_env_or_extra_str_opt(
  176. env_var: str, extra_val: str | bool | None, default: str | None = None
  177. ) -> str | None:
  178. """Get a value from environment variable or extra config."""
  179. env_value = os.getenv(env_var)
  180. if env_value is not None:
  181. return _parse_str_opt(env_value)
  182. return _parse_str_opt(extra_val, default)
  183. def _get_env_or_extra_str(
  184. env_var: str, extra_val: str | bool | None, default: str | None = None
  185. ) -> str:
  186. """Get a string value from environment variable or extra config."""
  187. my_string = _get_env_or_extra_str_opt(env_var, extra_val, default)
  188. if my_string:
  189. return my_string
  190. return ""
  191. def _get_env_or_extra_truthy(
  192. env_var: str, extra_val: str | bool | None, default: bool = False
  193. ) -> bool:
  194. """Get a boolean value from environment variable or extra config."""
  195. env_value = os.getenv(env_var)
  196. if env_value is not None:
  197. return _parse_truthy(env_value, default)
  198. return _parse_truthy(extra_val, default)
  199. def _get_env_or_extra_list(env_var: str, extra_val: list[str] | None) -> list[str]:
  200. """Get a list of values from environment variable or extra config."""
  201. env_value = os.getenv(env_var)
  202. if env_value is not None:
  203. return _parse_list(env_value)
  204. return _parse_list(extra_val)
  205. def _get_env_or_extra_int(env_var: str, extra_val: Any, default: int) -> int:
  206. """Get an integer value from environment variable or extra config."""
  207. env_value = os.getenv(env_var)
  208. if env_value is None or not env_value.strip():
  209. return _parse_int(extra_val, env_var, default)
  210. return _parse_int(env_value, env_var, default)
  211. T = TypeVar("T")
  212. class ConfigValue(Generic[T]):
  213. """The resolved value of a single config field, bound to one configuration
  214. instance.
  215. Access the payload via ``.value``. ``__bool__``/``__eq__`` delegate to it,
  216. so a forgotten ``.value`` (``if config.allow_all_users:``) still evaluates
  217. the actual setting instead of the always-truthy wrapper object.
  218. """
  219. __slots__ = ("env_name", "explicit", "field_name", "value")
  220. def __init__(
  221. self, value: T, field_name: str, env_name: str, explicit: bool = False
  222. ) -> None:
  223. self.value = value
  224. self.field_name = field_name
  225. self.env_name = env_name
  226. # explicit: True when the value came from the user (env var or extra),
  227. # False when it is merely the declared default. Defaults are
  228. # structurally indistinguishable from user input by value alone —
  229. # a user *may* deliberately set base_url to the default string —
  230. # so the origin must be carried alongside the value.
  231. self.explicit = explicit
  232. def __bool__(self) -> bool:
  233. return bool(self.value)
  234. def __eq__(self, other: object) -> bool:
  235. if isinstance(other, ConfigValue):
  236. return self.value == other.value
  237. return self.value == other
  238. def __hash__(self) -> int:
  239. return hash(self.value)
  240. def __contains__(self, item: Any) -> bool:
  241. return item in self.value # type: ignore[operator]
  242. def __iter__(self):
  243. return iter(self.value) # type: ignore[call-overload]
  244. def __str__(self) -> str:
  245. return str(self.value)
  246. def __repr__(self) -> str:
  247. return f"{self.field_name}={self.value!r}"
  248. class ConfigField(Generic[T]):
  249. """Declarative descriptor for one config field.
  250. Declares *how* a field is read (kind, env var name, default); the resolved
  251. payload lives per configuration instance in ``instance._values``, never on
  252. the descriptor itself. Reading a field on the class (rather than on an
  253. instance) yields the descriptor, so ``ChattoConfiguration.token.env_name``
  254. keeps working for the registration hooks.
  255. """
  256. field_name: str = ""
  257. env_name: str = ""
  258. def __init__(
  259. self,
  260. kind: str,
  261. *,
  262. default: Any = None,
  263. config_key: str | None = None,
  264. doc: str = "",
  265. ) -> None:
  266. # kind: "str" | "str_opt" | "bool" | "list"
  267. self.kind = kind
  268. self.default = default
  269. # config_key: the name used in config.yaml's "extra" block and (upper-cased,
  270. # CHATTO_-prefixed) as the env var, where it differs from the attribute name.
  271. self._config_key = config_key
  272. self.__doc__ = doc
  273. def __set_name__(self, owner: Any, name: str) -> None:
  274. self.field_name = str(name).lower().replace("chatto_", "", 1)
  275. self.config_key = self._config_key or self.field_name
  276. self.env_name = f"CHATTO_{self.config_key.upper()}"
  277. def __get__(self, instance: Any, owner: Any = None) -> "ConfigValue[T]":
  278. if instance is None:
  279. return self # type: ignore[return-value]
  280. return instance._values[self.field_name]
  281. def __set__(self, instance: Any, value: Any) -> None:
  282. raise AttributeError(
  283. f"Chatto: config field '{self.field_name}' is read-only; "
  284. f"set '{self.field_name}.value' if you really need to override it."
  285. )
  286. def coerce(self, raw: Any) -> Any:
  287. """Parse one raw value the way ``kind`` implies, reading no source.
  288. Every path into a field's payload ends here, so a value that arrives
  289. from the profile-.env overlay is typed exactly like one read from the
  290. environment or config.yaml. It has to be: the ``.env`` spells a list as
  291. a comma-separated string and a bool as ``"false"``, and consumers index,
  292. concatenate and test truthiness on these fields — a list field holding
  293. a string turns ``room_id in require + optional`` into a substring test,
  294. and ``"false"`` is true.
  295. """
  296. if self.kind == "list":
  297. return _parse_list(raw)
  298. if self.kind == "bool":
  299. return _parse_truthy(raw, bool(self.default))
  300. if self.kind == "int":
  301. return _parse_int(raw, self.env_name, int(self.default))
  302. if self.kind == "str_opt":
  303. return _parse_str_opt(raw, self.default)
  304. return _parse_str(raw, self.default)
  305. def resolve(self, extra: dict[str, Any]) -> ConfigValue:
  306. """Read this field from the environment, then from ``extra``, then the default."""
  307. raw = extra.get(self.config_key)
  308. # "The user set it somewhere" — a blank value still counts as set here,
  309. # because for a bool that is the only way to tell an explicit "false"
  310. # from the declared default.
  311. raw_present = os.getenv(self.env_name) is not None or raw is not None
  312. if self.kind == "list":
  313. value: Any = _get_env_or_extra_list(self.env_name, raw)
  314. elif self.kind == "bool":
  315. value = _get_env_or_extra_truthy(self.env_name, raw, bool(self.default))
  316. elif self.kind == "int":
  317. value = _get_env_or_extra_int(self.env_name, raw, int(self.default))
  318. elif self.kind == "str_opt":
  319. value = _get_env_or_extra_str_opt(self.env_name, raw, self.default)
  320. else:
  321. value = _get_env_or_extra_str(self.env_name, raw, self.default)
  322. # A blank value is a placeholder, not intent: `CHATTO_TOKEN=` left
  323. # behind by the installer must not count as "the user set the token",
  324. # or the tool-layer fallback — which yields to explicit values — would
  325. # never reach the real secret in the profile's .env. A bool is never
  326. # blank, so `False` there stays explicit.
  327. blank = value is None or value == "" or value == []
  328. explicit = raw_present and not blank
  329. return ConfigValue(value, self.field_name, self.env_name, explicit=explicit)
  330. class ChattoConfiguration:
  331. """Chatto platform configuration.
  332. Every field is resolved once per instance, in this order:
  333. environment variable → ``PlatformConfig.extra`` → declared default.
  334. """
  335. base_url = ConfigField("str", default=ChattoClient.DEFAULT_BASE_URL)
  336. token = ConfigField("str_opt")
  337. home_channel = ConfigField("str")
  338. allowed_users = ConfigField("list")
  339. # Participation in multi-person rooms is opt-in: require_mention_rooms
  340. # lists rooms where the bot answers only when addressed
  341. # (@name/@all/@here), optional_mention_rooms lists rooms where it answers
  342. # everything. A room in neither list stays silent — read-only, never
  343. # seeded into context or answered. Chatto has no group rooms like Signal:
  344. # every non-DM surface is a channel-kind room, so these two lists are the
  345. # only inbound gate; DMs always answer.
  346. require_mention_rooms = ConfigField("list")
  347. optional_mention_rooms = ConfigField("list")
  348. # Auto-thread: by default, Chatto creates a thread for replies to room
  349. # messages (not DMs, not already in a thread). This keeps conversations
  350. # organized in the room. Can be disabled via extra.auto_thread=false.
  351. auto_thread = ConfigField("bool", default=True)
  352. allow_all_users = ConfigField("bool", default=False)
  353. reactions = ConfigField("bool", default=True)
  354. # Inbound edits: a message_edited event re-runs the admission gates against
  355. # the new body. A message currently being processed is cancelled and
  356. # re-dispatched with the corrected text; a message that never passed the
  357. # gates (e.g. a forgotten @mention) gets a fresh turn; an already-answered
  358. # message stays answered.
  359. edit_dispatch = ConfigField("bool", default=True)
  360. # How long after posting an edit may still land, in seconds — edits to
  361. # hours-old messages must not resurrect old conversations. Parsed by
  362. # ConfigField's "int" kind; unparseable input falls back to the default.
  363. edit_window = ConfigField("int", default=300)
  364. def __init__(self, pconfig: PlatformConfig):
  365. """Resolve every declared ConfigField against env vars and
  366. ``PlatformConfig.extra`` (which Hermes pre-populates from config.yaml).
  367. """
  368. extra: dict[str, Any] = getattr(pconfig, "extra", None) or {}
  369. self._values: dict[str, ConfigValue] = {
  370. field.field_name: field.resolve(extra) for field in self.fields()
  371. }
  372. logger.debug("ChattoConfiguration: %s", self)
  373. @classmethod
  374. def fields(cls) -> list[ConfigField]:
  375. """All declared config fields, in declaration order."""
  376. return [v for v in vars(cls).values() if isinstance(v, ConfigField)]
  377. def overlay_profile_secrets(self, secrets: Mapping[str, str]) -> None:
  378. """Fill every field that is not an explicit user value from ``secrets``.
  379. Keyed by env var name. The plugin tools call this with the profile's own
  380. .env, which a cron or CLI process no longer sees through the
  381. environment: there, a secret would otherwise resolve to its default and
  382. the tool would report "token not configured" for a configured bot.
  383. An EXPLICIT value (env var or config.yaml extra) is the user's own
  384. intent and wins; a declared default or a blank placeholder yields here.
  385. The secret is parsed through ``ConfigField.coerce`` like any other raw
  386. input — a ``.env`` line is a string whatever the field means.
  387. """
  388. for config_field in self.fields():
  389. current = self._values.get(config_field.field_name)
  390. if current is not None and current.explicit:
  391. continue
  392. secret_value = secrets.get(config_field.env_name)
  393. if not secret_value:
  394. continue
  395. self._values[config_field.field_name] = ConfigValue(
  396. config_field.coerce(secret_value),
  397. config_field.field_name,
  398. config_field.env_name,
  399. explicit=True,
  400. )
  401. def __str__(self) -> str:
  402. redacted = {"token"}
  403. parts = [
  404. f"{name}={'***' if name in redacted and cv.value else cv.value!r}"
  405. for name, cv in self._values.items()
  406. ]
  407. return "ChattoConfiguration(" + ", ".join(parts) + ")"