groupme_driver.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. from __future__ import annotations
  2. from datetime import datetime
  3. import json
  4. import traceback
  5. import asyncio
  6. import logging
  7. import os
  8. import time
  9. import toml
  10. from fastapi import FastAPI, BackgroundTasks, Response
  11. from pydantic import BaseModel
  12. import rollbot
  13. from commands import config
  14. logging.config.fileConfig("logging.conf", disable_existing_loggers=False)
  15. with open(os.environ.get("SECRET_FILE", "secrets.toml"), "r") as sfile:
  16. secrets = toml.load(sfile)
  17. database_file = os.environ.get("DATABASE_FILE", secrets["database_file"])
  18. groupme_bots = secrets["groupme"]["bots"]
  19. groupme_token = secrets["groupme"]["token"]
  20. groupme_admins = secrets["admins"]["origin"]
  21. group_admins = secrets["admins"]["channel"]
  22. max_msg_len = 1000
  23. split_text = "\n..."
  24. msg_slice = max_msg_len - len(split_text)
  25. class GroupMeMessage(BaseModel):
  26. id: str
  27. sender_id: str
  28. group_id: str
  29. name: str
  30. text: str
  31. created_at: int
  32. attachments: list[dict[str, str]]
  33. class GroupMeBot(rollbot.Rollbot[GroupMeMessage]):
  34. def __init__(self):
  35. super().__init__(config.extend(rollbot.CommandConfiguration(bangs=("!",))), database_file)
  36. def read_config(self, key: str) -> Any:
  37. cfg = secrets
  38. for part in key.split("."):
  39. cfg = cfg.get(part, None)
  40. if cfg is None:
  41. return None
  42. return cfg
  43. def _convert_attachment(self, group_id, att_type, att_body):
  44. if att_type == "reply":
  45. async def fetch_reply():
  46. try:
  47. async with self.context.request.get(
  48. f"https://api.groupme.com/v3/groups/{group_id}/messages",
  49. headers={
  50. "Content-Type": "application/json",
  51. },
  52. params={
  53. "token": groupme_token,
  54. "limit": 1,
  55. "after_id": att_body["base_reply_id"],
  56. },
  57. ) as resp:
  58. msg = (await resp.json())["response"]["messages"][0]
  59. return json.dumps(msg)
  60. except:
  61. self.context.logger.exception("Failed to look up attached message")
  62. return rollbot.Attachment(att_type, fetch_reply)
  63. return rollbot.Attachment(att_type, json.dumps(att_body))
  64. async def parse(self, msg: GroupMeMessage):
  65. return rollbot.Message(
  66. origin_id="GROUPME",
  67. channel_id=msg.group_id,
  68. sender_id=msg.sender_id,
  69. timestamp=datetime.fromtimestamp(msg.created_at),
  70. origin_admin=msg.sender_id in groupme_admins,
  71. channel_admin=msg.sender_id in group_admins.get(msg.group_id, ()),
  72. sender_name=msg.name,
  73. text=msg.text,
  74. attachments=[self._convert_attachment(msg.group_id, d["type"], d) for d in msg.attachments],
  75. message_id=msg.id,
  76. )
  77. async def upload_image(self, data: bytes):
  78. async with self.context.request.post(
  79. "https://image.groupme.com/pictures",
  80. headers={
  81. "Content-Type": "image/png",
  82. "X-Access-Token": groupme_token,
  83. },
  84. data=data,
  85. ) as upload:
  86. upload.raise_for_status()
  87. return (await upload.json())["payload"]["url"]
  88. async def post_message(self, bot_id: str, text: str, attachments: list[dict[str, str]]):
  89. body = {
  90. "bot_id": bot_id,
  91. "text": text,
  92. "attachments": attachments,
  93. }
  94. self.context.logger.info(f"Sending: {body}")
  95. result = await self.context.request.post(
  96. "https://api.groupme.com/v3/bots/post",
  97. json=body,
  98. timeout=10,
  99. )
  100. self.context.logger.info(f"Received: {result.status} - {await result.text()}")
  101. async def respond(self, res: rollbot.Response):
  102. if res.cause is not None and (proc_time := time.time() - res.cause.received_at) < 1:
  103. # sleep for a moment to make groupme not misorder messages
  104. await asyncio.sleep(1 - proc_time)
  105. if res.origin_id != "GROUPME":
  106. self.context.logger.error(f"Unable to respond to {res.origin_id}")
  107. return
  108. bot_id = groupme_bots.get(res.channel_id, None)
  109. if bot_id is None:
  110. self.context.logger.error(f"Unable to respond to group {res.channel_id} in GroupMe")
  111. return
  112. message = ""
  113. attachments = []
  114. try:
  115. if res.attachments is not None:
  116. for att in res.attachments:
  117. if att.name == "image":
  118. if isinstance(att.body, bytes):
  119. attachments.append(
  120. {
  121. "type": "image",
  122. "url": await self.upload_image(att.body),
  123. }
  124. )
  125. else:
  126. attachments.append({"type": "image", "url": att.body})
  127. if att.name == "reply":
  128. if att.body is None or not isinstance(att.body, str):
  129. raise ValueError("Invalid reply body type, must be message ID")
  130. attachments.append({
  131. "type": "reply",
  132. "base_reply_id": att.body,
  133. "reply_id": att.body,
  134. })
  135. except:
  136. self.context.debugging = "".join(traceback.format_exc())
  137. message += "Failed to upload one or more attachments!\n"
  138. self.context.logger.exception("Failed to upload attachment")
  139. if res.text is not None:
  140. message += res.text
  141. msgs = []
  142. while len(message) > max_msg_len:
  143. msgs.append(message[:msg_slice] + split_text)
  144. message = message[msg_slice:]
  145. msgs.append(message)
  146. await self.post_message(bot_id, msgs[0], attachments)
  147. for m in msgs[1:]:
  148. await asyncio.sleep(0.1)
  149. await self.post_message(bot_id, m, [])
  150. app = FastAPI()
  151. bot = GroupMeBot()
  152. @app.on_event("startup")
  153. async def startup():
  154. await bot.on_startup()
  155. @app.on_event("shutdown")
  156. async def shutdown():
  157. await bot.on_shutdown()
  158. @app.post("/", status_code=204)
  159. async def receive(message: GroupMeMessage, tasks: BackgroundTasks):
  160. tasks.add_task(bot.on_message, message)
  161. return Response(status_code=204)
  162. @app.get("/health", status_code=204)
  163. def health():
  164. return Response(status_code=204)