api.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. # This file was taken from the repository poe-api https://github.com/ading2210/poe-api and is unmodified
  2. # This file is licensed under the GNU GPL v3 and written by @ading2210
  3. # license:
  4. # ading2210/poe-api: a reverse engineered Python API wrapepr for Quora's Poe
  5. # Copyright (C) 2023 ading2210
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. # You should have received a copy of the GNU General Public License
  15. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  16. import requests
  17. import re
  18. import json
  19. import random
  20. import logging
  21. import time
  22. import queue
  23. import threading
  24. import traceback
  25. import hashlib
  26. import string
  27. import random
  28. import requests.adapters
  29. import websocket
  30. from pathlib import Path
  31. from urllib.parse import urlparse
  32. parent_path = Path(__file__).resolve().parent
  33. queries_path = parent_path / "graphql"
  34. queries = {}
  35. logging.basicConfig()
  36. logger = logging.getLogger()
  37. user_agent = "Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0"
  38. def load_queries():
  39. for path in queries_path.iterdir():
  40. if path.suffix != ".graphql":
  41. continue
  42. with open(path) as f:
  43. queries[path.stem] = f.read()
  44. def generate_payload(query_name, variables):
  45. return {
  46. "query": queries[query_name],
  47. "variables": variables
  48. }
  49. def request_with_retries(method, *args, **kwargs):
  50. attempts = kwargs.get("attempts") or 10
  51. url = args[0]
  52. for i in range(attempts):
  53. r = method(*args, **kwargs)
  54. if r.status_code == 200:
  55. return r
  56. logger.warn(
  57. f"Server returned a status code of {r.status_code} while downloading {url}. Retrying ({i+1}/{attempts})...")
  58. raise RuntimeError(f"Failed to download {url} too many times.")
  59. class Client:
  60. gql_url = "https://poe.com/api/gql_POST"
  61. gql_recv_url = "https://poe.com/api/receive_POST"
  62. home_url = "https://poe.com"
  63. settings_url = "https://poe.com/api/settings"
  64. def __init__(self, token, proxy=None):
  65. self.proxy = proxy
  66. self.session = requests.Session()
  67. self.adapter = requests.adapters.HTTPAdapter(
  68. pool_connections=100, pool_maxsize=100)
  69. self.session.mount("http://", self.adapter)
  70. self.session.mount("https://", self.adapter)
  71. if proxy:
  72. self.session.proxies = {
  73. "http": self.proxy,
  74. "https": self.proxy
  75. }
  76. logger.info(f"Proxy enabled: {self.proxy}")
  77. self.active_messages = {}
  78. self.message_queues = {}
  79. self.session.cookies.set("p-b", token, domain="poe.com")
  80. self.headers = {
  81. "User-Agent": user_agent,
  82. "Referrer": "https://poe.com/",
  83. "Origin": "https://poe.com",
  84. }
  85. self.session.headers.update(self.headers)
  86. self.setup_connection()
  87. self.connect_ws()
  88. def setup_connection(self):
  89. self.ws_domain = f"tch{random.randint(1, 1e6)}"
  90. self.next_data = self.get_next_data(overwrite_vars=True)
  91. self.channel = self.get_channel_data()
  92. self.bots = self.get_bots(download_next_data=False)
  93. self.bot_names = self.get_bot_names()
  94. self.gql_headers = {
  95. "poe-formkey": self.formkey,
  96. "poe-tchannel": self.channel["channel"],
  97. }
  98. self.gql_headers = {**self.gql_headers, **self.headers}
  99. self.subscribe()
  100. def extract_formkey(self, html):
  101. script_regex = r'<script>if\(.+\)throw new Error;(.+)</script>'
  102. script_text = re.search(script_regex, html).group(1)
  103. key_regex = r'var .="([0-9a-f]+)",'
  104. key_text = re.search(key_regex, script_text).group(1)
  105. cipher_regex = r'.\[(\d+)\]=.\[(\d+)\]'
  106. cipher_pairs = re.findall(cipher_regex, script_text)
  107. formkey_list = [""] * len(cipher_pairs)
  108. for pair in cipher_pairs:
  109. formkey_index, key_index = map(int, pair)
  110. formkey_list[formkey_index] = key_text[key_index]
  111. formkey = "".join(formkey_list)
  112. return formkey
  113. def get_next_data(self, overwrite_vars=False):
  114. logger.info("Downloading next_data...")
  115. r = request_with_retries(self.session.get, self.home_url)
  116. json_regex = r'<script id="__NEXT_DATA__" type="application\/json">(.+?)</script>'
  117. json_text = re.search(json_regex, r.text).group(1)
  118. next_data = json.loads(json_text)
  119. if overwrite_vars:
  120. self.formkey = self.extract_formkey(r.text)
  121. self.viewer = next_data["props"]["pageProps"]["payload"]["viewer"]
  122. self.next_data = next_data
  123. return next_data
  124. def get_bot(self, display_name):
  125. url = f'https://poe.com/_next/data/{self.next_data["buildId"]}/{display_name}.json'
  126. r = request_with_retries(self.session.get, url)
  127. chat_data = r.json()["pageProps"]["payload"]["chatOfBotDisplayName"]
  128. return chat_data
  129. def get_bots(self, download_next_data=True):
  130. logger.info("Downloading all bots...")
  131. if download_next_data:
  132. next_data = self.get_next_data(overwrite_vars=True)
  133. else:
  134. next_data = self.next_data
  135. if not "availableBots" in self.viewer:
  136. raise RuntimeError("Invalid token or no bots are available.")
  137. bot_list = self.viewer["availableBots"]
  138. threads = []
  139. bots = {}
  140. def get_bot_thread(bot):
  141. chat_data = self.get_bot(bot["displayName"])
  142. bots[chat_data["defaultBotObject"]["nickname"]] = chat_data
  143. for bot in bot_list:
  144. thread = threading.Thread(
  145. target=get_bot_thread, args=(bot,), daemon=True)
  146. threads.append(thread)
  147. for thread in threads:
  148. thread.start()
  149. for thread in threads:
  150. thread.join()
  151. self.bots = bots
  152. self.bot_names = self.get_bot_names()
  153. return bots
  154. def get_bot_names(self):
  155. bot_names = {}
  156. for bot_nickname in self.bots:
  157. bot_obj = self.bots[bot_nickname]["defaultBotObject"]
  158. bot_names[bot_nickname] = bot_obj["displayName"]
  159. return bot_names
  160. def get_remaining_messages(self, chatbot):
  161. chat_data = self.get_bot(self.bot_names[chatbot])
  162. return chat_data["defaultBotObject"]["messageLimit"]["numMessagesRemaining"]
  163. def get_channel_data(self, channel=None):
  164. logger.info("Downloading channel data...")
  165. r = request_with_retries(self.session.get, self.settings_url)
  166. data = r.json()
  167. return data["tchannelData"]
  168. def get_websocket_url(self, channel=None):
  169. if channel is None:
  170. channel = self.channel
  171. query = f'?min_seq={channel["minSeq"]}&channel={channel["channel"]}&hash={channel["channelHash"]}'
  172. return f'wss://{self.ws_domain}.tch.{channel["baseHost"]}/up/{channel["boxName"]}/updates'+query
  173. def send_query(self, query_name, variables):
  174. for i in range(20):
  175. json_data = generate_payload(query_name, variables)
  176. payload = json.dumps(json_data, separators=(",", ":"))
  177. base_string = payload + \
  178. self.gql_headers["poe-formkey"] + "WpuLMiXEKKE98j56k"
  179. headers = {
  180. "content-type": "application/json",
  181. "poe-tag-id": hashlib.md5(base_string.encode()).hexdigest()
  182. }
  183. headers = {**self.gql_headers, **headers}
  184. r = request_with_retries(
  185. self.session.post, self.gql_url, data=payload, headers=headers)
  186. data = r.json()
  187. if data["data"] == None:
  188. logger.warn(
  189. f'{query_name} returned an error: {data["errors"][0]["message"]} | Retrying ({i+1}/20)')
  190. time.sleep(2)
  191. continue
  192. return r.json()
  193. raise RuntimeError(f'{query_name} failed too many times.')
  194. def subscribe(self):
  195. logger.info("Subscribing to mutations")
  196. result = self.send_query("SubscriptionsMutation", {
  197. "subscriptions": [
  198. {
  199. "subscriptionName": "messageAdded",
  200. "query": queries["MessageAddedSubscription"]
  201. },
  202. {
  203. "subscriptionName": "viewerStateUpdated",
  204. "query": queries["ViewerStateUpdatedSubscription"]
  205. }
  206. ]
  207. })
  208. def ws_run_thread(self):
  209. kwargs = {}
  210. if self.proxy:
  211. proxy_parsed = urlparse(self.proxy)
  212. kwargs = {
  213. "proxy_type": proxy_parsed.scheme,
  214. "http_proxy_host": proxy_parsed.hostname,
  215. "http_proxy_port": proxy_parsed.port
  216. }
  217. self.ws.run_forever(**kwargs)
  218. def connect_ws(self):
  219. self.ws_connected = False
  220. self.ws = websocket.WebSocketApp(
  221. self.get_websocket_url(),
  222. header={"User-Agent": user_agent},
  223. on_message=self.on_message,
  224. on_open=self.on_ws_connect,
  225. on_error=self.on_ws_error,
  226. on_close=self.on_ws_close
  227. )
  228. t = threading.Thread(target=self.ws_run_thread, daemon=True)
  229. t.start()
  230. while not self.ws_connected:
  231. time.sleep(0.01)
  232. def disconnect_ws(self):
  233. if self.ws:
  234. self.ws.close()
  235. self.ws_connected = False
  236. def on_ws_connect(self, ws):
  237. self.ws_connected = True
  238. def on_ws_close(self, ws, close_status_code, close_message):
  239. self.ws_connected = False
  240. logger.warn(
  241. f"Websocket closed with status {close_status_code}: {close_message}")
  242. def on_ws_error(self, ws, error):
  243. self.disconnect_ws()
  244. self.connect_ws()
  245. def on_message(self, ws, msg):
  246. try:
  247. data = json.loads(msg)
  248. if not "messages" in data:
  249. return
  250. for message_str in data["messages"]:
  251. message_data = json.loads(message_str)
  252. if message_data["message_type"] != "subscriptionUpdate":
  253. continue
  254. message = message_data["payload"]["data"]["messageAdded"]
  255. copied_dict = self.active_messages.copy()
  256. for key, value in copied_dict.items():
  257. # add the message to the appropriate queue
  258. if value == message["messageId"] and key in self.message_queues:
  259. self.message_queues[key].put(message)
  260. return
  261. # indicate that the response id is tied to the human message id
  262. elif key != "pending" and value == None and message["state"] != "complete":
  263. self.active_messages[key] = message["messageId"]
  264. self.message_queues[key].put(message)
  265. return
  266. except Exception:
  267. logger.error(traceback.format_exc())
  268. self.disconnect_ws()
  269. self.connect_ws()
  270. def send_message(self, chatbot, message, with_chat_break=False, timeout=20):
  271. # if there is another active message, wait until it has finished sending
  272. while None in self.active_messages.values():
  273. time.sleep(0.01)
  274. # None indicates that a message is still in progress
  275. self.active_messages["pending"] = None
  276. logger.info(f"Sending message to {chatbot}: {message}")
  277. # reconnect websocket
  278. if not self.ws_connected:
  279. self.disconnect_ws()
  280. self.setup_connection()
  281. self.connect_ws()
  282. message_data = self.send_query("SendMessageMutation", {
  283. "bot": chatbot,
  284. "query": message,
  285. "chatId": self.bots[chatbot]["chatId"],
  286. "source": None,
  287. "withChatBreak": with_chat_break
  288. })
  289. del self.active_messages["pending"]
  290. if not message_data["data"]["messageEdgeCreate"]["message"]:
  291. raise RuntimeError(f"Daily limit reached for {chatbot}.")
  292. try:
  293. human_message = message_data["data"]["messageEdgeCreate"]["message"]
  294. human_message_id = human_message["node"]["messageId"]
  295. except TypeError:
  296. raise RuntimeError(
  297. f"An unknown error occurred. Raw response data: {message_data}")
  298. # indicate that the current message is waiting for a response
  299. self.active_messages[human_message_id] = None
  300. self.message_queues[human_message_id] = queue.Queue()
  301. last_text = ""
  302. message_id = None
  303. while True:
  304. try:
  305. message = self.message_queues[human_message_id].get(
  306. timeout=timeout)
  307. except queue.Empty:
  308. del self.active_messages[human_message_id]
  309. del self.message_queues[human_message_id]
  310. raise RuntimeError("Response timed out.")
  311. # only break when the message is marked as complete
  312. if message["state"] == "complete":
  313. if last_text and message["messageId"] == message_id:
  314. break
  315. else:
  316. continue
  317. # update info about response
  318. message["text_new"] = message["text"][len(last_text):]
  319. last_text = message["text"]
  320. message_id = message["messageId"]
  321. yield message
  322. del self.active_messages[human_message_id]
  323. del self.message_queues[human_message_id]
  324. def send_chat_break(self, chatbot):
  325. logger.info(f"Sending chat break to {chatbot}")
  326. result = self.send_query("AddMessageBreakMutation", {
  327. "chatId": self.bots[chatbot]["chatId"]
  328. })
  329. return result["data"]["messageBreakCreate"]["message"]
  330. def get_message_history(self, chatbot, count=25, cursor=None):
  331. logger.info(f"Downloading {count} messages from {chatbot}")
  332. messages = []
  333. if cursor == None:
  334. chat_data = self.get_bot(self.bot_names[chatbot])
  335. if not chat_data["messagesConnection"]["edges"]:
  336. return []
  337. messages = chat_data["messagesConnection"]["edges"][:count]
  338. cursor = chat_data["messagesConnection"]["pageInfo"]["startCursor"]
  339. count -= len(messages)
  340. cursor = str(cursor)
  341. if count > 50:
  342. messages = self.get_message_history(
  343. chatbot, count=50, cursor=cursor) + messages
  344. while count > 0:
  345. count -= 50
  346. new_cursor = messages[0]["cursor"]
  347. new_messages = self.get_message_history(
  348. chatbot, min(50, count), cursor=new_cursor)
  349. messages = new_messages + messages
  350. return messages
  351. elif count <= 0:
  352. return messages
  353. result = self.send_query("ChatListPaginationQuery", {
  354. "count": count,
  355. "cursor": cursor,
  356. "id": self.bots[chatbot]["id"]
  357. })
  358. query_messages = result["data"]["node"]["messagesConnection"]["edges"]
  359. messages = query_messages + messages
  360. return messages
  361. def delete_message(self, message_ids):
  362. logger.info(f"Deleting messages: {message_ids}")
  363. if not type(message_ids) is list:
  364. message_ids = [int(message_ids)]
  365. result = self.send_query("DeleteMessageMutation", {
  366. "messageIds": message_ids
  367. })
  368. def purge_conversation(self, chatbot, count=-1):
  369. logger.info(f"Purging messages from {chatbot}")
  370. last_messages = self.get_message_history(chatbot, count=50)[::-1]
  371. while last_messages:
  372. message_ids = []
  373. for message in last_messages:
  374. if count == 0:
  375. break
  376. count -= 1
  377. message_ids.append(message["node"]["messageId"])
  378. self.delete_message(message_ids)
  379. if count == 0:
  380. return
  381. last_messages = self.get_message_history(chatbot, count=50)[::-1]
  382. logger.info(f"No more messages left to delete.")
  383. def create_bot(self, handle, prompt="", base_model="chinchilla", description="",
  384. intro_message="", api_key=None, api_bot=False, api_url=None,
  385. prompt_public=True, pfp_url=None, linkification=False,
  386. markdown_rendering=True, suggested_replies=False, private=False):
  387. result = self.send_query("PoeBotCreateMutation", {
  388. "model": base_model,
  389. "handle": handle,
  390. "prompt": prompt,
  391. "isPromptPublic": prompt_public,
  392. "introduction": intro_message,
  393. "description": description,
  394. "profilePictureUrl": pfp_url,
  395. "apiUrl": api_url,
  396. "apiKey": api_key,
  397. "isApiBot": api_bot,
  398. "hasLinkification": linkification,
  399. "hasMarkdownRendering": markdown_rendering,
  400. "hasSuggestedReplies": suggested_replies,
  401. "isPrivateBot": private
  402. })
  403. data = result["data"]["poeBotCreate"]
  404. if data["status"] != "success":
  405. raise RuntimeError(
  406. f"Poe returned an error while trying to create a bot: {data['status']}")
  407. self.get_bots()
  408. return data
  409. def edit_bot(self, bot_id, handle, prompt="", base_model="chinchilla", description="",
  410. intro_message="", api_key=None, api_url=None, private=False,
  411. prompt_public=True, pfp_url=None, linkification=False,
  412. markdown_rendering=True, suggested_replies=False):
  413. result = self.send_query("PoeBotEditMutation", {
  414. "baseBot": base_model,
  415. "botId": bot_id,
  416. "handle": handle,
  417. "prompt": prompt,
  418. "isPromptPublic": prompt_public,
  419. "introduction": intro_message,
  420. "description": description,
  421. "profilePictureUrl": pfp_url,
  422. "apiUrl": api_url,
  423. "apiKey": api_key,
  424. "hasLinkification": linkification,
  425. "hasMarkdownRendering": markdown_rendering,
  426. "hasSuggestedReplies": suggested_replies,
  427. "isPrivateBot": private
  428. })
  429. data = result["data"]["poeBotEdit"]
  430. if data["status"] != "success":
  431. raise RuntimeError(
  432. f"Poe returned an error while trying to edit a bot: {data['status']}")
  433. self.get_bots()
  434. return data
  435. load_queries()
粤ICP备19079148号