feishu.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import json
  2. from app.infra.shared import AsyncHttpClient
  3. class Feishu:
  4. # 服务号分组群发监测机器人
  5. server_account_publish_monitor_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/380fdecf-402e-4426-85b6-7d9dbd2a9f59"
  6. # 外部服务号投流监测机器人
  7. outside_gzh_monitor_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/0899d43d-9f65-48ce-a419-f83ac935bf59"
  8. # 长文 daily 报警机器人
  9. long_articles_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/b44333f2-16c0-4cb1-af01-d135f8704410"
  10. # 测试环境报警机器人
  11. long_articles_bot_dev = "https://open.feishu.cn/open-apis/bot/v2/hook/f32c0456-847f-41f3-97db-33fcc1616bcd"
  12. # 长文任务报警群
  13. long_articles_task_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/223b3d72-f2e8-40e0-9b53-6956e0ae7158"
  14. # cookie 监测机器人
  15. cookie_monitor_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/51b9c83a-f50d-44dd-939f-bcd10ac6c55a"
  16. # rank_bot
  17. rank_monitor_bot = "https://open.feishu.cn/open-apis/bot/v2/hook/f9dae7ba-decf-436b-b438-41994c35af1e"
  18. # 用户名与手机号映射
  19. name_phone_dict = {
  20. "卓异": "18624010360",
  21. "俊辉": "18801281360",
  22. "范军": "15200827642",
  23. "林强": "15810236123",
  24. }
  25. def __init__(self):
  26. self.token = None
  27. self.headers = {"Content-Type": "application/json"}
  28. self.mention_all = {
  29. "content": "<at id=all></at>\n",
  30. "tag": "lark_md",
  31. }
  32. self.not_mention = {}
  33. async def fetch_token(self):
  34. url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
  35. post_data = {
  36. "app_id": "cli_a51114cf8bf8d00c",
  37. "app_secret": "cNoTAqMpsAm7mPBcpCAXFfvOzCNL27fe",
  38. }
  39. async with AsyncHttpClient(default_headers=self.headers) as client:
  40. response = await client.post(url=url, json=post_data)
  41. tenant_access_token = response["tenant_access_token"]
  42. self.token = tenant_access_token
  43. async def get_user_open_id(self, username):
  44. """
  45. 根据用户名获取飞书 open_id
  46. :param username: 用户名
  47. :return: open_id
  48. """
  49. if not self.token:
  50. await self.fetch_token()
  51. mobile = self.name_phone_dict.get(username)
  52. if not mobile:
  53. return None
  54. url = "https://open.feishu.cn/open-apis/user/v1/batch_get_id"
  55. headers = {
  56. "Authorization": f"Bearer {self.token}",
  57. "Content-Type": "application/json; charset=utf-8",
  58. }
  59. params = {"mobiles": [mobile]}
  60. async with AsyncHttpClient() as client:
  61. response = await client.get(url=url, params=params, headers=headers)
  62. try:
  63. open_id = response["data"]["mobile_users"][mobile][0]["open_id"]
  64. return open_id
  65. except (KeyError, IndexError):
  66. return None
  67. class FeishuSheetApi(Feishu):
  68. async def read_sheet_range(
  69. self, spreadsheet_token: str, range_str: str
  70. ) -> list[list]:
  71. """读取电子表格指定范围的数据。
  72. Args:
  73. spreadsheet_token: 电子表格 token
  74. range_str: 查询范围,格式为 "<sheet_id>!<开始位置>:<结束位置>",如 "4d54bd!A1:K358"
  75. Returns:
  76. 数据二维数组。失败或无数据时返回空列表。
  77. """
  78. if not self.token:
  79. await self.fetch_token()
  80. url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{spreadsheet_token}/values/{range_str}"
  81. print(self.token)
  82. headers = {
  83. "Authorization": f"Bearer {self.token}",
  84. "Content-Type": "application/json; charset=utf-8",
  85. }
  86. async with AsyncHttpClient() as client:
  87. response = await client.get(url=url, headers=headers)
  88. if response.get("code", 0) != 0:
  89. print(f"飞书读取表格失败: {response.get('msg', 'unknown error')}")
  90. return []
  91. data = response.get("data", {})
  92. value_range = data.get("valueRange", {})
  93. return value_range.get("values", [])
  94. async def prepend_value(self, sheet_token, sheet_id, ranges, values):
  95. insert_value_url = "https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{}/values_prepend".format(
  96. sheet_token
  97. )
  98. headers = {
  99. "Authorization": "Bearer " + self.token,
  100. "contentType": "application/json; charset=utf-8",
  101. }
  102. body = {
  103. "valueRange": {"range": "{}!{}".format(sheet_id, ranges), "values": values}
  104. }
  105. async with AsyncHttpClient() as client:
  106. response = await client.post(
  107. url=insert_value_url, json=body, headers=headers
  108. )
  109. print(response)
  110. async def insert_value(self, sheet_token, sheet_id, ranges, values):
  111. insert_value_url = (
  112. "https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{}/values".format(
  113. sheet_token
  114. )
  115. )
  116. # self.token = 't-g104bpfHNZN45BVJWFSQEM6WD45AAI4FNRWXCZVK'
  117. headers = {
  118. "Authorization": "Bearer " + self.token,
  119. "contentType": "application/json; charset=utf-8",
  120. }
  121. body = {
  122. "valueRange": {"range": "{}!{}".format(sheet_id, ranges), "values": values}
  123. }
  124. async with AsyncHttpClient() as client:
  125. response = await client.put(
  126. url=insert_value_url, json=body, headers=headers
  127. )
  128. class FeishuBotApi(Feishu):
  129. async def create_mention_text(self, usernames):
  130. """
  131. 创建 @ 用户的文本
  132. :param usernames: 用户名列表
  133. :return: @ 用户的 markdown 文本
  134. """
  135. if not usernames:
  136. return ""
  137. mention_parts = []
  138. for username in usernames:
  139. open_id = await self.get_user_open_id(username)
  140. if open_id:
  141. mention_parts.append(f"<at id={open_id}></at>")
  142. return " ".join(mention_parts) + "\n" if mention_parts else ""
  143. @classmethod
  144. def create_feishu_columns_sheet(
  145. cls,
  146. sheet_type,
  147. sheet_name,
  148. display_name,
  149. width="auto",
  150. vertical_align="top",
  151. horizontal_align="left",
  152. number_format=None,
  153. ):
  154. match sheet_type:
  155. case "plain_text":
  156. return {
  157. "name": sheet_name,
  158. "display_name": display_name,
  159. "width": width,
  160. "data_type": "text",
  161. "vertical_align": vertical_align,
  162. "horizontal_align": horizontal_align,
  163. }
  164. case "lark_md":
  165. return {
  166. "name": sheet_name,
  167. "display_name": display_name,
  168. "data_type": "lark_md",
  169. }
  170. case "number":
  171. return {
  172. "name": sheet_name,
  173. "display_name": display_name,
  174. "data_type": "number",
  175. "format": number_format,
  176. "width": width,
  177. }
  178. case "date":
  179. return {
  180. "name": sheet_name,
  181. "display_name": display_name,
  182. "data_type": "date",
  183. "date_format": "YYYY/MM/DD",
  184. }
  185. case "options":
  186. return {
  187. "name": sheet_name,
  188. "display_name": display_name,
  189. "data_type": "options",
  190. }
  191. case _:
  192. return {
  193. "name": sheet_name,
  194. "display_name": display_name,
  195. "width": width,
  196. "data_type": "text",
  197. "vertical_align": vertical_align,
  198. "horizontal_align": horizontal_align,
  199. }
  200. # 表格形式
  201. async def create_feishu_table(
  202. self, title, columns, rows, mention, mention_users=None
  203. ):
  204. """
  205. 创建飞书表格消息
  206. :param title: 标题
  207. :param columns: 列定义
  208. :param rows: 行数据
  209. :param mention: 是否 @ 所有人
  210. :param mention_users: 要 @ 的具体用户列表,例如 ["mark"]
  211. """
  212. mention_element = self.not_mention
  213. if mention:
  214. mention_element = self.mention_all
  215. elif mention_users:
  216. mention_text = await self.create_mention_text(mention_users)
  217. mention_element = {"content": mention_text, "tag": "lark_md"}
  218. table_base = {
  219. "header": {
  220. "template": "blue",
  221. "title": {"content": title, "tag": "plain_text"},
  222. },
  223. "elements": [
  224. mention_element,
  225. {
  226. "tag": "table",
  227. "page_size": len(rows) + 1,
  228. "row_height": "low",
  229. "header_style": {
  230. "text_align": "left",
  231. "text_size": "normal",
  232. "background_style": "grey",
  233. "text_color": "default",
  234. "bold": True,
  235. "lines": 1,
  236. },
  237. "columns": columns,
  238. "rows": rows,
  239. },
  240. ],
  241. }
  242. return table_base
  243. async def create_feishu_bot_obj(self, title, mention, detail, mention_users=None):
  244. """
  245. create feishu bot object
  246. :param title: 标题
  247. :param mention: 是否 @ 所有人
  248. :param detail: 详细内容
  249. :param mention_users: 要 @ 的具体用户列表,例如 ["luojunhui"]
  250. """
  251. mention_element = self.not_mention
  252. if mention:
  253. mention_element = self.mention_all
  254. elif mention_users:
  255. mention_text = await self.create_mention_text(mention_users)
  256. mention_element = {"content": mention_text, "tag": "lark_md"}
  257. return {
  258. "elements": [
  259. {
  260. "tag": "div",
  261. "text": mention_element,
  262. },
  263. {
  264. "tag": "div",
  265. "text": {
  266. "content": json.dumps(detail, ensure_ascii=False, indent=4),
  267. "tag": "lark_md",
  268. },
  269. },
  270. ],
  271. "header": {"title": {"content": title, "tag": "plain_text"}},
  272. }
  273. # bot
  274. async def bot(
  275. self,
  276. title,
  277. detail,
  278. mention=True,
  279. table=False,
  280. env="long_articles_task",
  281. mention_users=None,
  282. ):
  283. """
  284. 发送飞书机器人消息
  285. :param title: 标题
  286. :param detail: 详细内容
  287. :param mention: 是否 @ 所有人
  288. :param table: 是否为表格形式
  289. :param env: 环境,决定发送到哪个机器人
  290. :param mention_users: 要 @ 的具体用户列表,例如 ["luojunhui"]
  291. """
  292. match env:
  293. case "dev":
  294. url = self.long_articles_bot_dev
  295. case "prod":
  296. url = self.long_articles_bot
  297. case "outside_gzh_monitor":
  298. url = self.outside_gzh_monitor_bot
  299. case "server_account_publish_monitor":
  300. url = self.server_account_publish_monitor_bot
  301. case "long_articles_task":
  302. url = self.long_articles_task_bot
  303. case "cookie_monitor":
  304. url = self.cookie_monitor_bot
  305. case "rank_bot":
  306. url = self.rank_monitor_bot
  307. case _:
  308. url = self.long_articles_bot_dev
  309. headers = {"Content-Type": "application/json"}
  310. if table:
  311. card = await self.create_feishu_table(
  312. title=title,
  313. columns=detail["columns"],
  314. rows=detail["rows"],
  315. mention=mention,
  316. mention_users=mention_users,
  317. )
  318. else:
  319. card = await self.create_feishu_bot_obj(
  320. title=title, mention=mention, detail=detail, mention_users=mention_users
  321. )
  322. data = {"msg_type": "interactive", "card": card}
  323. async with AsyncHttpClient() as client:
  324. res = await client.post(url=url, headers=headers, json=data)
  325. return res