Experimental Discord bot written in Python
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

storage.py 6.1KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. """
  2. Handles storage of persisted and non-persisted data for the bot.
  3. """
  4. import json
  5. from datetime import datetime, timedelta, timezone
  6. from os.path import exists
  7. from typing import Any
  8. from discord import Guild
  9. from config import CONFIG
  10. from rocketbot.collections import AgeBoundDict
  11. from rocketbot.utils import norm_datetime
  12. class ConfigKey:
  13. """
  14. Common keys in persisted guild storage.
  15. """
  16. WARNING_CHANNEL_ID = 'warning_channel_id'
  17. WARNING_MENTION = 'warning_mention'
  18. class Storage:
  19. """
  20. Static class for managing persisted bot configuration and transient state
  21. on a per-guild basis.
  22. """
  23. # -- Transient state management -----------------------------------------
  24. __guild_id_to_state: dict[int, dict[str, Any]] = {} # noqa: RUF012
  25. @classmethod
  26. def get_state(cls, guild: Guild) -> dict[str, Any]:
  27. """
  28. Returns transient state for the given guild. This state is not preserved
  29. if the bot is restarted.
  30. """
  31. state: dict[str, Any] = cls.__guild_id_to_state.get(guild.id)
  32. if state is None:
  33. state = {}
  34. cls.__guild_id_to_state[guild.id] = state
  35. return state
  36. @classmethod
  37. def get_state_value(cls, guild: Guild, key: str) -> Any | None:
  38. """
  39. Returns a state value for the given guild and key, or `None` if not set.
  40. """
  41. return cls.get_state(guild).get(key)
  42. @classmethod
  43. def set_state_value(cls, guild: Guild, key: str, value: Any | None) -> None:
  44. """
  45. Updates a transient value associated with the given guild and key name.
  46. A value of `None` removes any previous value for that key.
  47. """
  48. cls.set_state_values(guild, { key: value })
  49. @classmethod
  50. def set_state_values(cls, guild: Guild, values: dict[str, Any | None] | None) -> None:
  51. """
  52. Merges in a set of key-value pairs into the transient state for the
  53. given guild. Any pairs with a value of `None` will be removed from the
  54. transient state.
  55. """
  56. if values is None or len(values) == 0:
  57. return
  58. state: dict[str, Any] = cls.get_state(guild)
  59. for key, value in values.items():
  60. if value is None:
  61. del state[key]
  62. else:
  63. state[key] = value
  64. # XXX: Superstitious. Should update by ref already but saw weirdness once.
  65. cls.__guild_id_to_state[guild.id] = state
  66. # -- Persisted configuration management ---------------------------------
  67. # discord.Guild.id -> dict
  68. __guild_id_to_config: dict[int, dict[str, Any]] = {} # noqa: RUF012
  69. @classmethod
  70. def get_config(cls, guild: Guild) -> dict[str, Any]:
  71. """
  72. Returns all persisted configuration for the given guild.
  73. """
  74. config: dict[str, Any] = cls.__guild_id_to_config.get(guild.id)
  75. if config is not None:
  76. # Already in memory
  77. return config
  78. # Load from disk if possible
  79. cls.__trace(f'No loaded config for guild {guild.id}. Attempting to ' +
  80. 'load from disk.')
  81. config = cls.__read_guild_config(guild)
  82. if config is None:
  83. config = {}
  84. cls.__guild_id_to_config[guild.id] = config
  85. return config
  86. @classmethod
  87. def get_config_value(cls, guild: Guild, key: str) -> Any | None:
  88. """
  89. Returns a persisted guild config value stored under the given key.
  90. Returns `None` if not present.
  91. """
  92. return cls.get_config(guild).get(key)
  93. @classmethod
  94. def set_config_value(cls, guild: Guild, key: str, value: Any | None) -> None:
  95. """
  96. Adds/updates the given key-value pair to the persisted config for the
  97. given Guild. If `value` is `None` the key will be removed from persisted
  98. config.
  99. """
  100. cls.set_config_values(guild, { key: value })
  101. @classmethod
  102. def set_config_values(cls, guild: Guild, values: dict[str, Any | None] | None) -> None:
  103. """
  104. Merges the given `values` dict with the saved config for the given guild
  105. and writes it to disk. `values` must be JSON-encodable or a `ValueError`
  106. will be raised. Keys with associated values of `None` will be removed
  107. from the persisted config.
  108. """
  109. if values is None or len(values) == 0:
  110. return
  111. config: dict[str, Any] = cls.get_config(guild)
  112. try:
  113. json.dumps(values)
  114. except Exception as e:
  115. raise ValueError(f'values not JSON encodable - {values}') from e
  116. for key, value in values.items():
  117. if value is None:
  118. del config[key]
  119. else:
  120. config[key] = value
  121. cls.__write_guild_config(guild, config)
  122. @classmethod
  123. def get_bot_messages(cls, guild: Guild) -> AgeBoundDict[int, Any, datetime, timedelta]:
  124. """Returns all the bot messages for a guild."""
  125. bm = cls.get_state_value(guild, 'bot_messages')
  126. if bm is None:
  127. far_future = datetime.now(timezone.utc) + timedelta(days=1000)
  128. bm = AgeBoundDict(timedelta(seconds=600),
  129. lambda k, v : norm_datetime(v.message_sent_at()) or far_future)
  130. Storage.set_state_value(guild, 'bot_messages', bm)
  131. return bm
  132. @classmethod
  133. def __write_guild_config(cls, guild: Guild, config: dict[str, Any]) -> None:
  134. """
  135. Saves config for a guild to a JSON file on disk.
  136. """
  137. path: str = cls.__guild_config_path(guild)
  138. cls.__trace(f'Saving config for guild {guild.id} to {path}')
  139. cls.__trace(f'config = {config}')
  140. config['_guild_name'] = guild.name # Just for making JSON files easier to identify
  141. with open(path, 'w', encoding='utf8') as file:
  142. # Pretty printing to make more legible for debugging
  143. # Sorting keys to help with diffs
  144. json.dump(config, file, indent='\t', sort_keys=True)
  145. cls.__trace('State saved')
  146. @classmethod
  147. def __read_guild_config(cls, guild: Guild) -> dict[str, Any] | None:
  148. """
  149. Loads config for a guild from a JSON file on disk, or `None` if not
  150. found.
  151. """
  152. path: str = cls.__guild_config_path(guild)
  153. if not exists(path):
  154. cls.__trace(f'No config on disk for guild {guild.id}. Returning None.')
  155. return None
  156. cls.__trace(f'Loading config from disk for guild {guild.id}')
  157. with open(path, 'r', encoding='utf8') as file:
  158. config = json.load(file)
  159. cls.__trace('State loaded')
  160. return config
  161. @classmethod
  162. def __guild_config_path(cls, guild: Guild) -> str:
  163. """
  164. Returns the JSON file path where guild config should be written.
  165. """
  166. config_value: str = CONFIG['config_path']
  167. path: str = config_value if config_value.endswith('/') else f'{config_value}/'
  168. return f'{path}guild_{guild.id}.json'
  169. @classmethod
  170. def __trace(cls, message: Any) -> None:
  171. # print(f'{cls.__name__}: {str(message)}')
  172. pass