From 1396122423bf2ed978be642b1e7eef5effb36bc3 Mon Sep 17 00:00:00 2001 From: "t.me/xtekky" <98614666+xtekky@users.noreply.github.com> Date: Wed, 29 Mar 2023 21:10:42 +0100 Subject: poe api gpt-4 --- README.md | 38 +++ poe/__init__.py | 214 ++++++++++++++ poe/api.py | 316 +++++++++++++++++++++ poe/cookies.txt | 1 + poe/graphql/AddHumanMessageMutation.graphql | 52 ++++ poe/graphql/AddMessageBreakMutation.graphql | 17 ++ poe/graphql/AutoSubscriptionMutation.graphql | 7 + poe/graphql/BioFragment.graphql | 8 + poe/graphql/ChatAddedSubscription.graphql | 5 + poe/graphql/ChatFragment.graphql | 6 + poe/graphql/ChatListPaginationQuery.graphql | 316 +++++++++++++++++++++ poe/graphql/ChatPaginationQuery.graphql | 26 ++ poe/graphql/ChatViewQuery.graphql | 8 + poe/graphql/DeleteHumanMessagesMutation.graphql | 7 + poe/graphql/DeleteMessageMutation.graphql | 7 + poe/graphql/HandleFragment.graphql | 8 + .../LoginWithVerificationCodeMutation.graphql | 13 + poe/graphql/MessageAddedSubscription.graphql | 100 +++++++ poe/graphql/MessageDeletedSubscription.graphql | 6 + poe/graphql/MessageFragment.graphql | 13 + poe/graphql/MessageRemoveVoteMutation.graphql | 7 + poe/graphql/MessageSetVoteMutation.graphql | 7 + .../SendVerificationCodeForLoginMutation.graphql | 12 + poe/graphql/ShareMessagesMutation.graphql | 9 + .../SignupWithVerificationCodeMutation.graphql | 13 + poe/graphql/StaleChatUpdateMutation.graphql | 7 + poe/graphql/SubscriptionsMutation.graphql | 9 + poe/graphql/SummarizePlainPostQuery.graphql | 3 + poe/graphql/SummarizeQuotePostQuery.graphql | 3 + poe/graphql/SummarizeSharePostQuery.graphql | 3 + poe/graphql/UserSnippetFragment.graphql | 14 + poe/graphql/ViewerInfoQuery.graphql | 21 ++ poe/graphql/ViewerStateFragment.graphql | 30 ++ poe/graphql/ViewerStateUpdatedSubscription.graphql | 43 +++ poe/graphql/__init__.py | 0 poe/mail.py | 62 ++++ 36 files changed, 1411 insertions(+) create mode 100644 poe/__init__.py create mode 100644 poe/api.py create mode 100644 poe/cookies.txt create mode 100644 poe/graphql/AddHumanMessageMutation.graphql create mode 100644 poe/graphql/AddMessageBreakMutation.graphql create mode 100644 poe/graphql/AutoSubscriptionMutation.graphql create mode 100644 poe/graphql/BioFragment.graphql create mode 100644 poe/graphql/ChatAddedSubscription.graphql create mode 100644 poe/graphql/ChatFragment.graphql create mode 100644 poe/graphql/ChatListPaginationQuery.graphql create mode 100644 poe/graphql/ChatPaginationQuery.graphql create mode 100644 poe/graphql/ChatViewQuery.graphql create mode 100644 poe/graphql/DeleteHumanMessagesMutation.graphql create mode 100644 poe/graphql/DeleteMessageMutation.graphql create mode 100644 poe/graphql/HandleFragment.graphql create mode 100644 poe/graphql/LoginWithVerificationCodeMutation.graphql create mode 100644 poe/graphql/MessageAddedSubscription.graphql create mode 100644 poe/graphql/MessageDeletedSubscription.graphql create mode 100644 poe/graphql/MessageFragment.graphql create mode 100644 poe/graphql/MessageRemoveVoteMutation.graphql create mode 100644 poe/graphql/MessageSetVoteMutation.graphql create mode 100644 poe/graphql/SendVerificationCodeForLoginMutation.graphql create mode 100644 poe/graphql/ShareMessagesMutation.graphql create mode 100644 poe/graphql/SignupWithVerificationCodeMutation.graphql create mode 100644 poe/graphql/StaleChatUpdateMutation.graphql create mode 100644 poe/graphql/SubscriptionsMutation.graphql create mode 100644 poe/graphql/SummarizePlainPostQuery.graphql create mode 100644 poe/graphql/SummarizeQuotePostQuery.graphql create mode 100644 poe/graphql/SummarizeSharePostQuery.graphql create mode 100644 poe/graphql/UserSnippetFragment.graphql create mode 100644 poe/graphql/ViewerInfoQuery.graphql create mode 100644 poe/graphql/ViewerStateFragment.graphql create mode 100644 poe/graphql/ViewerStateUpdatedSubscription.graphql create mode 100644 poe/graphql/__init__.py create mode 100644 poe/mail.py diff --git a/README.md b/README.md index abecd989..a6a4523c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,43 @@ working on it... +`poe` (use like openai pypi package) - gpt-4 + +Import poe: + +```python +import poe + +# poe.Account.create +# poe.Completion.create +# poe.StreamCompletion.create +``` + +Create Token (3-6s) +```python +token = poe.Account.create(logging = True) +print('token', token) +``` + +Streaming Response +```python + +for response in poe.StreamingCompletion.create(model = 'gpt-4', + prompt = 'hello world', + token = token): + + print(response.completion.choices[0].text, end="", flush=True) +``` + +Normal Response: +```python + +response = poe.Completion.create(model = 'gpt-4', + prompt = 'hello world', + token = token) + +print(response.completion.choices[0].text) +``` + `t3nsor` (use like openai pypi package) Import t3nsor: diff --git a/poe/__init__.py b/poe/__init__.py new file mode 100644 index 00000000..3f75ba14 --- /dev/null +++ b/poe/__init__.py @@ -0,0 +1,214 @@ +from poe.api import Client as PoeClient +from poe.mail import Mail +from tls_client import Session +from re import search, findall +from json import loads +from time import sleep, time +from pathlib import Path +from random import choice +from urllib import parse + +class PoeResponse: + + class Completion: + + class Choices: + def __init__(self, choice: dict) -> None: + self.text = choice['text'] + self.content = self.text.encode() + self.index = choice['index'] + self.logprobs = choice['logprobs'] + self.finish_reason = choice['finish_reason'] + + def __repr__(self) -> str: + return f'''<__main__.APIResponse.Completion.Choices(\n text = {self.text.encode()},\n index = {self.index},\n logprobs = {self.logprobs},\n finish_reason = {self.finish_reason})object at 0x1337>''' + + def __init__(self, choices: dict) -> None: + self.choices = [self.Choices(choice) for choice in choices] + + class Usage: + def __init__(self, usage_dict: dict) -> None: + self.prompt_tokens = usage_dict['prompt_tokens'] + self.completion_tokens = usage_dict['completion_tokens'] + self.total_tokens = usage_dict['total_tokens'] + + def __repr__(self): + return f'''<__main__.APIResponse.Usage(\n prompt_tokens = {self.prompt_tokens},\n completion_tokens = {self.completion_tokens},\n total_tokens = {self.total_tokens})object at 0x1337>''' + + def __init__(self, response_dict: dict) -> None: + + self.response_dict = response_dict + self.id = response_dict['id'] + self.object = response_dict['object'] + self.created = response_dict['created'] + self.model = response_dict['model'] + self.completion = self.Completion(response_dict['choices']) + self.usage = self.Usage(response_dict['usage']) + + def json(self) -> dict: + return self.response_dict + + +class Account: + def create(proxy: None or str = None, logging: bool = False): + + client = Session(client_identifier = "chrome110") + client.proxies = { + 'http': f'http://{proxy}', + 'https': f'http://{proxy}'} if proxy else None + + mail = Mail(client.proxies) + mail_token = None + mail_address = mail.get_mail() + + if logging: print('email', mail_address) + + client.headers = { + "host" : "poe.com", + "connection" : "keep-alive", + "cache-control" : "max-age=0", + "sec-ch-ua" : "\"Microsoft Edge\";v=\"111\", \"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"111\"", + "sec-ch-ua-mobile" : "?0", + "sec-ch-ua-platform": "\"macOS\"", + "user-agent" : "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36 Edg/111.0.1661.54", + "accept" : "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "sec-fetch-site" : "same-origin", + "sec-fetch-mode" : "navigate", + "sec-fetch-user" : "?1", + "sec-fetch-dest" : "document", + "accept-encoding" : "gzip, deflate, br", + "accept-language" : "en-GB,en;q=0.9,en-US;q=0.8", + "upgrade-insecure-requests": "1", + } + + init = client.get('https://poe.com/login') + next_data = loads(search(r'json">(.+?)', init.text).group(1)) + + client.headers["poe-formkey"] = next_data['props']['formkey'] + client.headers["poe-tchannel"] = client.get('https://poe.com/api/settings').json()['tchannelData']['channel'] + + payload = { + "queryName": "MainSignupLoginSection_sendVerificationCodeMutation_Mutation", + "variables": { + "emailAddress": mail_address, + "phoneNumber" : None + }, + "query": "mutation MainSignupLoginSection_sendVerificationCodeMutation_Mutation(\n $emailAddress: String\n $phoneNumber: String\n) {\n sendVerificationCode(verificationReason: login, emailAddress: $emailAddress, phoneNumber: $phoneNumber) {\n status\n errorMessage\n }\n}\n" + } + + response = client.post('https://poe.com/api/gql_POST', json=payload) + if 'Bad Request' in response.text: + if logging: print('bad request, retrying...' , response.json()) + Account.create(proxy = proxy, logging = logging) + + if logging: print('send_code' ,response.json()) + + while True: + sleep(1) + inbox = mail.fetch_inbox() + + for _ in inbox: + content = mail.get_message(_["id"]) + mail_token = findall(r';">(\d{6,7})', content['html'][0])[0] + + if mail_token: + break + + if logging: print('code', mail_token) + + payload = { + "queryName": "SignupOrLoginWithCodeSection_signupWithVerificationCodeMutation_Mutation", + "variables": { + "verificationCode" : mail_token, + "emailAddress" : mail_address, + "phoneNumber" : None + }, + "query": "mutation SignupOrLoginWithCodeSection_signupWithVerificationCodeMutation_Mutation(\n $verificationCode: String!\n $emailAddress: String\n $phoneNumber: String\n) {\n signupWithVerificationCode(verificationCode: $verificationCode, emailAddress: $emailAddress, phoneNumber: $phoneNumber) {\n status\n errorMessage\n }\n}\n" + } + + response = client.post('https://poe.com/api/gql_POST', json = payload) + if logging: print('verify_code', response.json()) + + token = parse.unquote(client.cookies.get_dict()['p-b']) + + with open(Path(__file__).resolve().parent / 'cookies.txt', 'a') as f: + f.write(f'{token}\n') + + return token + + def get(): + cookies = open(Path(__file__).resolve().parent / 'cookies.txt', 'r').read().splitlines() + return choice(cookies) + +class StreamingCompletion: + def create( + model : str = 'gpt-4', + prompt: str = 'hello world', + token : str = ''): + + models = { + 'sage' : 'capybara', + 'gpt-4' : 'beaver', + 'claude+': 'a2_2', + 'claude' : 'a2', + 'gpt-3.5': 'chinchilla' + } + + client = PoeClient(token) + + for chunk in client.send_message(models[model], prompt): + + yield PoeResponse({ + 'id' : chunk["messageId"], + 'object' : 'text_completion', + 'created': chunk['creationTime'], + 'model' : models[model], + 'choices': [{ + 'text' : chunk["text_new"], + 'index' : 0, + 'logprobs' : None, + 'finish_reason' : 'stop' + }], + 'usage': { + 'prompt_tokens' : len(prompt), + 'completion_tokens' : len(chunk["text_new"]), + 'total_tokens' : len(prompt) + len(chunk["text_new"]) + } + }) + +class Completion: + def create( + model : str = 'gpt-4', + prompt: str = 'hello world', + token : str = ''): + + models = { + 'sage' : 'capybara', + 'gpt-4' : 'beaver', + 'claude+': 'a2_2', + 'claude' : 'a2', + 'gpt-3.5': 'chinchilla' + } + + client = PoeClient(token) + + for chunk in client.send_message(models[model], prompt): + pass + + return PoeResponse({ + 'id' : chunk["messageId"], + 'object' : 'text_completion', + 'created': chunk['creationTime'], + 'model' : models[model], + 'choices': [{ + 'text' : chunk["text"], + 'index' : 0, + 'logprobs' : None, + 'finish_reason' : 'stop' + }], + 'usage': { + 'prompt_tokens' : len(prompt), + 'completion_tokens' : len(chunk["text"]), + 'total_tokens' : len(prompt) + len(chunk["text"]) + } + }) \ No newline at end of file diff --git a/poe/api.py b/poe/api.py new file mode 100644 index 00000000..baaa1338 --- /dev/null +++ b/poe/api.py @@ -0,0 +1,316 @@ +# ading2210/poe-api: a reverse engineered Python API wrapepr for Quora's Poe +# Copyright (C) 2023 ading2210 + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import requests +import re +import json +import random +import logging +import time +import queue +import threading +import websocket +from pathlib import Path +from urllib.parse import urlparse + +parent_path = Path(__file__).resolve().parent +queries_path = parent_path / "graphql" +queries = {} + +logging.basicConfig() +logger = logging.getLogger() + +user_agent = "Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0" + + +def load_queries(): + for path in queries_path.iterdir(): + if path.suffix != ".graphql": + continue + with open(path) as f: + queries[path.stem] = f.read() + + +def generate_payload(query_name, variables): + return { + "query": queries[query_name], + "variables": variables + } + + +def request_with_retries(method, *args, **kwargs): + attempts = kwargs.get("attempts") or 10 + url = args[0] + for i in range(attempts): + r = method(*args, **kwargs) + if r.status_code == 200: + return r + logger.warn( + f"Server returned a status code of {r.status_code} while downloading {url}. Retrying ({i+1}/{attempts})...") + + raise RuntimeError(f"Failed to download {url} too many times.") + + +class Client: + gql_url = "https://poe.com/api/gql_POST" + gql_recv_url = "https://poe.com/api/receive_POST" + home_url = "https://poe.com" + settings_url = "https://poe.com/api/settings" + + formkey = "" + next_data = {} + bots = {} + active_messages = {} + message_queues = {} + ws = None + ws_connected = False + + def __init__(self, token, proxy=None): + self.proxy = proxy + self.session = requests.Session() + + if proxy: + self.session.proxies = { + "http": self.proxy, + "https": self.proxy + } + logger.info(f"Proxy enabled: {self.proxy}") + + self.session.cookies.set("p-b", token, domain="poe.com") + self.headers = { + "User-Agent": user_agent, + "Referrer": "https://poe.com/", + "Origin": "https://poe.com", + } + self.ws_domain = f"tch{random.randint(1, 1e6)}" + + self.session.headers.update(self.headers) + self.next_data = self.get_next_data() + self.channel = self.get_channel_data() + self.connect_ws() + self.bots = self.get_bots() + self.bot_names = self.get_bot_names() + + self.gql_headers = { + "poe-formkey": self.formkey, + "poe-tchannel": self.channel["channel"], + } + self.gql_headers = {**self.gql_headers, **self.headers} + self.subscribe() + + def get_next_data(self): + logger.info("Downloading next_data...") + + r = request_with_retries(self.session.get, self.home_url) + json_regex = r'' + json_text = re.search(json_regex, r.text).group(1) + next_data = json.loads(json_text) + + self.formkey = next_data["props"]["formkey"] + self.viewer = next_data["props"]["pageProps"]["payload"]["viewer"] + + return next_data + + def get_bots(self): + viewer = self.next_data["props"]["pageProps"]["payload"]["viewer"] + if not "availableBots" in viewer: + raise RuntimeError("Invalid token.") + bot_list = viewer["availableBots"] + + bots = {} + for bot in bot_list: + url = f'https://poe.com/_next/data/{self.next_data["buildId"]}/{bot["displayName"].lower()}.json' + logger.info("Downloading "+url) + + r = request_with_retries(self.session.get, url) + + chat_data = r.json()[ + "pageProps"]["payload"]["chatOfBotDisplayName"] + bots[chat_data["defaultBotObject"]["nickname"]] = chat_data + + return bots + + def get_bot_names(self): + bot_names = {} + for bot_nickname in self.bots: + bot_obj = self.bots[bot_nickname]["defaultBotObject"] + bot_names[bot_nickname] = bot_obj["displayName"] + return bot_names + + def get_channel_data(self, channel=None): + logger.info("Downloading channel data...") + r = request_with_retries(self.session.get, self.settings_url) + data = r.json() + + self.formkey = data["formkey"] + return data["tchannelData"] + + def get_websocket_url(self, channel=None): + if channel is None: + channel = self.channel + query = f'?min_seq={channel["minSeq"]}&channel={channel["channel"]}&hash={channel["channelHash"]}' + return f'wss://{self.ws_domain}.tch.{channel["baseHost"]}/up/{channel["boxName"]}/updates'+query + + def send_query(self, query_name, variables): + # print(f'send_query: {query_name} {variables}') + + for i in range(20): + payload = generate_payload(query_name, variables) + # print(f'query_payload: {query_name} {variables}') + r = request_with_retries( + self.session.post, self.gql_url, json=payload, headers=self.gql_headers) + data = r.json() + if data["data"] == None: + logger.warn( + f'{query_name} returned an error: {data["errors"][0]["message"]} | Retrying ({i+1}/20)') + time.sleep(2) + continue + + return r.json() + + raise RuntimeError(f'{query_name} failed too many times.') + + def subscribe(self): + logger.info("Subscribing to mutations") + result = self.send_query("SubscriptionsMutation", { + "subscriptions": [ + { + "subscriptionName": "messageAdded", + "query": queries["MessageAddedSubscription"] + }, + { + "subscriptionName": "viewerStateUpdated", + "query": queries["ViewerStateUpdatedSubscription"] + } + ] + }) + + def ws_run_thread(self): + kwargs = {} + if self.proxy: + proxy_parsed = urlparse(self.proxy) + kwargs = { + "proxy_type": proxy_parsed.scheme, + "http_proxy_host": proxy_parsed.hostname, + "http_proxy_port": proxy_parsed.port + } + + self.ws.run_forever(**kwargs) + + def connect_ws(self): + self.ws = websocket.WebSocketApp( + self.get_websocket_url(), + header={"User-Agent": user_agent}, + on_message=self.on_message, + on_open=self.on_ws_connect, + on_error=self.on_ws_error + ) + t = threading.Thread(target=self.ws_run_thread, daemon=True) + t.start() + while not self.ws_connected: + time.sleep(0.01) + + def disconnect_ws(self): + if self.ws: + self.ws.close() + self.ws_connected = False + + def on_ws_connect(self, ws): + self.ws_connected = True + + def on_ws_error(self, ws, error): + logger.warn(f"Websocket returned error: {error}") + self.disconnect_ws() + self.connect_ws() + + def on_message(self, ws, msg): + data = json.loads(msg) + message = json.loads(data["messages"][0])[ + "payload"]["data"]["messageAdded"] + + copied_dict = self.active_messages.copy() + for key, value in copied_dict.items(): + # add the message to the appropriate queue + if value == message["messageId"] and key in self.message_queues: + self.message_queues[key].put(message) + return + + # indicate that the response id is tied to the human message id + elif key != "pending" and value == None and message["state"] != "complete": + self.active_messages[key] = message["messageId"] + self.message_queues[key].put(message) + + def send_message(self, chatbot, message, with_chat_break=False, timeout=20): + # if there is another active message, wait until it has finished sending + while None in self.active_messages.values(): + time.sleep(0.01) + + # None indicates that a message is still in progress + self.active_messages["pending"] = None + + logger.info(f"Sending message to {chatbot}: {message}") + + message_data = self.send_query("AddHumanMessageMutation", { + "bot": chatbot, + "query": message, + "chatId": self.bots[chatbot]["chatId"], + "source": None, + "withChatBreak": with_chat_break + }) + del self.active_messages["pending"] + + if not message_data["data"]["messageCreateWithStatus"]["messageLimit"]["canSend"]: + raise RuntimeError(f"Daily limit reached for {chatbot}.") + try: + human_message = message_data["data"]["messageCreateWithStatus"] + human_message_id = human_message["message"]["messageId"] + except TypeError: + raise RuntimeError( + f"An unknown error occured. Raw response data: {message_data}") + + # indicate that the current message is waiting for a response + self.active_messages[human_message_id] = None + self.message_queues[human_message_id] = queue.Queue() + + last_text = "" + message_id = None + while True: + try: + message = self.message_queues[human_message_id].get( + timeout=timeout) + except queue.Empty: + del self.active_messages[human_message_id] + del self.message_queues[human_message_id] + raise RuntimeError("Response timed out.") + + # only break when the message is marked as complete + if message["state"] == "complete": + if last_text and message["messageId"] == message_id: + break + else: + continue + + # update info about response + message["text_new"] = message["text"][len(last_text):] + last_text = message["text"] + message_id = message["messageId"] + + yield message + + del self.active_messages[human_message_id] + del self.message_queues[human_message_id] + +load_queries() diff --git a/poe/cookies.txt b/poe/cookies.txt new file mode 100644 index 00000000..26850464 --- /dev/null +++ b/poe/cookies.txt @@ -0,0 +1 @@ +SmPiNXZI9hBTuf3viz74PA== diff --git a/poe/graphql/AddHumanMessageMutation.graphql b/poe/graphql/AddHumanMessageMutation.graphql new file mode 100644 index 00000000..01e6bc8c --- /dev/null +++ b/poe/graphql/AddHumanMessageMutation.graphql @@ -0,0 +1,52 @@ +mutation AddHumanMessageMutation( + $chatId: BigInt! + $bot: String! + $query: String! + $source: MessageSource + $withChatBreak: Boolean! = false +) { + messageCreateWithStatus( + chatId: $chatId + bot: $bot + query: $query + source: $source + withChatBreak: $withChatBreak + ) { + message { + id + __typename + messageId + text + linkifiedText + authorNickname + state + vote + voteReason + creationTime + suggestedReplies + chat { + id + shouldShowDisclaimer + } + } + messageLimit{ + canSend + numMessagesRemaining + resetTime + shouldShowReminder + } + chatBreak { + id + __typename + messageId + text + linkifiedText + authorNickname + state + vote + voteReason + creationTime + suggestedReplies + } + } +} diff --git a/poe/graphql/AddMessageBreakMutation.graphql b/poe/graphql/AddMessageBreakMutation.graphql new file mode 100644 index 00000000..b28d9903 --- /dev/null +++ b/poe/graphql/AddMessageBreakMutation.graphql @@ -0,0 +1,17 @@ +mutation AddMessageBreakMutation($chatId: BigInt!) { + messageBreakCreate(chatId: $chatId) { + message { + id + __typename + messageId + text + linkifiedText + authorNickname + state + vote + voteReason + creationTime + suggestedReplies + } + } +} diff --git a/poe/graphql/AutoSubscriptionMutation.graphql b/poe/graphql/AutoSubscriptionMutation.graphql new file mode 100644 index 00000000..6cf7bf74 --- /dev/null +++ b/poe/graphql/AutoSubscriptionMutation.graphql @@ -0,0 +1,7 @@ +mutation AutoSubscriptionMutation($subscriptions: [AutoSubscriptionQuery!]!) { + autoSubscribe(subscriptions: $subscriptions) { + viewer { + id + } + } +} diff --git a/poe/graphql/BioFragment.graphql b/poe/graphql/BioFragment.graphql new file mode 100644 index 00000000..c4218030 --- /dev/null +++ b/poe/graphql/BioFragment.graphql @@ -0,0 +1,8 @@ +fragment BioFragment on Viewer { + id + poeUser { + id + uid + bio + } +} diff --git a/poe/graphql/ChatAddedSubscription.graphql b/poe/graphql/ChatAddedSubscription.graphql new file mode 100644 index 00000000..664b107f --- /dev/null +++ b/poe/graphql/ChatAddedSubscription.graphql @@ -0,0 +1,5 @@ +subscription ChatAddedSubscription { + chatAdded { + ...ChatFragment + } +} diff --git a/poe/graphql/ChatFragment.graphql b/poe/graphql/ChatFragment.graphql new file mode 100644 index 00000000..605645ff --- /dev/null +++ b/poe/graphql/ChatFragment.graphql @@ -0,0 +1,6 @@ +fragment ChatFragment on Chat { + id + chatId + defaultBotNickname + shouldShowDisclaimer +} diff --git a/poe/graphql/ChatListPaginationQuery.graphql b/poe/graphql/ChatListPaginationQuery.graphql new file mode 100644 index 00000000..0bbaf568 --- /dev/null +++ b/poe/graphql/ChatListPaginationQuery.graphql @@ -0,0 +1,316 @@ +query ChatListPaginationQuery( + $count: Int = 5 + $cursor: String + $id: ID! +) { + node(id: $id) { + __typename + ...ChatPageMain_chat_1G22uz + id + } +} + +fragment BotImage_bot on Bot { + image { + __typename + ... on LocalBotImage { + localName + } + ... on UrlBotImage { + url + } + } + displayName +} + +fragment ChatMessageDownvotedButton_message on Message { + ...MessageFeedbackReasonModal_message + ...MessageFeedbackOtherModal_message +} + +fragment ChatMessageDropdownMenu_message on Message { + id + messageId + vote + text + linkifiedText + ...chatHelpers_isBotMessage +} + +fragment ChatMessageFeedbackButtons_message on Message { + id + messageId + vote + voteReason + ...ChatMessageDownvotedButton_message +} + +fragment ChatMessageInputView_chat on Chat { + id + chatId + defaultBotObject { + nickname + messageLimit { + dailyBalance + shouldShowRemainingMessageCount + } + id + } + shouldShowDisclaimer + ...chatHelpers_useSendMessage_chat + ...chatHelpers_useSendChatBreak_chat +} + +fragment ChatMessageInputView_edges on MessageEdge { + node { + ...chatHelpers_isChatBreak + ...chatHelpers_isHumanMessage + state + text + id + } +} + +fragment ChatMessageOverflowButton_message on Message { + text + ...ChatMessageDropdownMenu_message + ...chatHelpers_isBotMessage +} + +fragment ChatMessageSuggestedReplies_SuggestedReplyButton_chat on Chat { + ...chatHelpers_useSendMessage_chat +} + +fragment ChatMessageSuggestedReplies_SuggestedReplyButton_message on Message { + messageId +} + +fragment ChatMessageSuggestedReplies_chat on Chat { + ...ChatWelcomeView_chat + ...ChatMessageSuggestedReplies_SuggestedReplyButton_chat +} + +fragment ChatMessageSuggestedReplies_message on Message { + suggestedReplies + ...ChatMessageSuggestedReplies_SuggestedReplyButton_message +} + +fragment ChatMessage_chat on Chat { + defaultBotObject { + ...ChatPageDisclaimer_bot + messageLimit { + ...ChatPageRateLimitedBanner_messageLimit + } + id + } + ...ChatMessageSuggestedReplies_chat + ...ChatWelcomeView_chat +} + +fragment ChatMessage_message on Message { + id + messageId + text + author + linkifiedText + state + ...ChatMessageSuggestedReplies_message + ...ChatMessageFeedbackButtons_message + ...ChatMessageOverflowButton_message + ...chatHelpers_isHumanMessage + ...chatHelpers_isBotMessage + ...chatHelpers_isChatBreak + ...chatHelpers_useTimeoutLevel + ...MarkdownLinkInner_message +} + +fragment ChatMessagesView_chat on Chat { + ...ChatMessage_chat + ...ChatWelcomeView_chat + defaultBotObject { + messageLimit { + ...ChatPageRateLimitedBanner_messageLimit + } + id + } +} + +fragment ChatMessagesView_edges on MessageEdge { + node { + id + messageId + creationTime + ...ChatMessage_message + ...chatHelpers_isBotMessage + ...chatHelpers_isHumanMessage + ...chatHelpers_isChatBreak + } +} + +fragment ChatPageDeleteFooter_chat on Chat { + ...MessageDeleteConfirmationModal_chat +} + +fragment ChatPageDisclaimer_bot on Bot { + disclaimer +} + +fragment ChatPageMain_chat_1G22uz on Chat { + id + chatId + ...ChatMessageInputView_chat + ...ChatPageShareFooter_chat + ...ChatPageDeleteFooter_chat + ...ChatMessagesView_chat + ...MarkdownLinkInner_chat + ...chatHelpers_useUpdateStaleChat_chat + ...ChatSubscriptionPaywallContextWrapper_chat + messagesConnection(last: $count, before: $cursor) { + edges { + ...ChatMessagesView_edges + ...ChatMessageInputView_edges + ...MarkdownLinkInner_edges + node { + ...chatHelpers_useUpdateStaleChat_message + id + __typename + } + cursor + id + } + pageInfo { + hasPreviousPage + startCursor + } + id + } +} + +fragment ChatPageRateLimitedBanner_messageLimit on MessageLimit { + numMessagesRemaining +} + +fragment ChatPageShareFooter_chat on Chat { + chatId +} + +fragment ChatSubscriptionPaywallContextWrapper_chat on Chat { + defaultBotObject { + messageLimit { + numMessagesRemaining + shouldShowRemainingMessageCount + } + ...SubscriptionPaywallModal_bot + id + } +} + +fragment ChatWelcomeView_ChatWelcomeButton_chat on Chat { + ...chatHelpers_useSendMessage_chat +} + +fragment ChatWelcomeView_chat on Chat { + ...ChatWelcomeView_ChatWelcomeButton_chat + defaultBotObject { + displayName + id + } +} + +fragment MarkdownLinkInner_chat on Chat { + id + chatId + defaultBotObject { + nickname + id + } + ...chatHelpers_useSendMessage_chat +} + +fragment MarkdownLinkInner_edges on MessageEdge { + node { + state + id + } +} + +fragment MarkdownLinkInner_message on Message { + messageId +} + +fragment MessageDeleteConfirmationModal_chat on Chat { + id +} + +fragment MessageFeedbackOtherModal_message on Message { + id + messageId +} + +fragment MessageFeedbackReasonModal_message on Message { + id + messageId +} + +fragment SubscriptionPaywallModal_bot on Bot { + displayName + messageLimit { + dailyLimit + numMessagesRemaining + shouldShowRemainingMessageCount + resetTime + } + ...BotImage_bot +} + +fragment chatHelpers_isBotMessage on Message { + ...chatHelpers_isHumanMessage + ...chatHelpers_isChatBreak +} + +fragment chatHelpers_isChatBreak on Message { + author +} + +fragment chatHelpers_isHumanMessage on Message { + author +} + +fragment chatHelpers_useSendChatBreak_chat on Chat { + id + chatId + defaultBotObject { + nickname + introduction + model + id + } + shouldShowDisclaimer +} + +fragment chatHelpers_useSendMessage_chat on Chat { + id + chatId + defaultBotObject { + nickname + id + } + shouldShowDisclaimer +} + +fragment chatHelpers_useTimeoutLevel on Message { + id + state + text + messageId +} + +fragment chatHelpers_useUpdateStaleChat_chat on Chat { + chatId + ...chatHelpers_useSendChatBreak_chat +} + +fragment chatHelpers_useUpdateStaleChat_message on Message { + creationTime + ...chatHelpers_isChatBreak +} diff --git a/poe/graphql/ChatPaginationQuery.graphql b/poe/graphql/ChatPaginationQuery.graphql new file mode 100644 index 00000000..f2452cd6 --- /dev/null +++ b/poe/graphql/ChatPaginationQuery.graphql @@ -0,0 +1,26 @@ +query ChatPaginationQuery($bot: String!, $before: String, $last: Int! = 10) { + chatOfBot(bot: $bot) { + id + __typename + messagesConnection(before: $before, last: $last) { + pageInfo { + hasPreviousPage + } + edges { + node { + id + __typename + messageId + text + linkifiedText + authorNickname + state + vote + voteReason + creationTime + suggestedReplies + } + } + } + } +} diff --git a/poe/graphql/ChatViewQuery.graphql b/poe/graphql/ChatViewQuery.graphql new file mode 100644 index 00000000..c330107d --- /dev/null +++ b/poe/graphql/ChatViewQuery.graphql @@ -0,0 +1,8 @@ +query ChatViewQuery($bot: String!) { + chatOfBot(bot: $bot) { + id + chatId + defaultBotNickname + shouldShowDisclaimer + } +} diff --git a/poe/graphql/DeleteHumanMessagesMutation.graphql b/poe/graphql/DeleteHumanMessagesMutation.graphql new file mode 100644 index 00000000..42692c6e --- /dev/null +++ b/poe/graphql/DeleteHumanMessagesMutation.graphql @@ -0,0 +1,7 @@ +mutation DeleteHumanMessagesMutation($messageIds: [BigInt!]!) { + messagesDelete(messageIds: $messageIds) { + viewer { + id + } + } +} diff --git a/poe/graphql/DeleteMessageMutation.graphql b/poe/graphql/DeleteMessageMutation.graphql new file mode 100644 index 00000000..7b9e36d4 --- /dev/null +++ b/poe/graphql/DeleteMessageMutation.graphql @@ -0,0 +1,7 @@ +mutation deleteMessageMutation( + $messageIds: [BigInt!]! +) { + messagesDelete(messageIds: $messageIds) { + edgeIds + } +} \ No newline at end of file diff --git a/poe/graphql/HandleFragment.graphql b/poe/graphql/HandleFragment.graphql new file mode 100644 index 00000000..f53c484b --- /dev/null +++ b/poe/graphql/HandleFragment.graphql @@ -0,0 +1,8 @@ +fragment HandleFragment on Viewer { + id + poeUser { + id + uid + handle + } +} diff --git a/poe/graphql/LoginWithVerificationCodeMutation.graphql b/poe/graphql/LoginWithVerificationCodeMutation.graphql new file mode 100644 index 00000000..723b1f44 --- /dev/null +++ b/poe/graphql/LoginWithVerificationCodeMutation.graphql @@ -0,0 +1,13 @@ +mutation LoginWithVerificationCodeMutation( + $verificationCode: String! + $emailAddress: String + $phoneNumber: String +) { + loginWithVerificationCode( + verificationCode: $verificationCode + emailAddress: $emailAddress + phoneNumber: $phoneNumber + ) { + status + } +} diff --git a/poe/graphql/MessageAddedSubscription.graphql b/poe/graphql/MessageAddedSubscription.graphql new file mode 100644 index 00000000..8dc9499c --- /dev/null +++ b/poe/graphql/MessageAddedSubscription.graphql @@ -0,0 +1,100 @@ +subscription messageAdded ( + $chatId: BigInt! +) { + messageAdded(chatId: $chatId) { + id + messageId + creationTime + state + ...ChatMessage_message + ...chatHelpers_isBotMessage + } +} + +fragment ChatMessageDownvotedButton_message on Message { + ...MessageFeedbackReasonModal_message + ...MessageFeedbackOtherModal_message +} + +fragment ChatMessageDropdownMenu_message on Message { + id + messageId + vote + text + linkifiedText + ...chatHelpers_isBotMessage +} + +fragment ChatMessageFeedbackButtons_message on Message { + id + messageId + vote + voteReason + ...ChatMessageDownvotedButton_message +} + +fragment ChatMessageOverflowButton_message on Message { + text + ...ChatMessageDropdownMenu_message + ...chatHelpers_isBotMessage +} + +fragment ChatMessageSuggestedReplies_SuggestedReplyButton_message on Message { + messageId +} + +fragment ChatMessageSuggestedReplies_message on Message { + suggestedReplies + ...ChatMessageSuggestedReplies_SuggestedReplyButton_message +} + +fragment ChatMessage_message on Message { + id + messageId + text + author + linkifiedText + state + ...ChatMessageSuggestedReplies_message + ...ChatMessageFeedbackButtons_message + ...ChatMessageOverflowButton_message + ...chatHelpers_isHumanMessage + ...chatHelpers_isBotMessage + ...chatHelpers_isChatBreak + ...chatHelpers_useTimeoutLevel + ...MarkdownLinkInner_message +} + +fragment MarkdownLinkInner_message on Message { + messageId +} + +fragment MessageFeedbackOtherModal_message on Message { + id + messageId +} + +fragment MessageFeedbackReasonModal_message on Message { + id + messageId +} + +fragment chatHelpers_isBotMessage on Message { + ...chatHelpers_isHumanMessage + ...chatHelpers_isChatBreak +} + +fragment chatHelpers_isChatBreak on Message { + author +} + +fragment chatHelpers_isHumanMessage on Message { + author +} + +fragment chatHelpers_useTimeoutLevel on Message { + id + state + text + messageId +} diff --git a/poe/graphql/MessageDeletedSubscription.graphql b/poe/graphql/MessageDeletedSubscription.graphql new file mode 100644 index 00000000..54c1c164 --- /dev/null +++ b/poe/graphql/MessageDeletedSubscription.graphql @@ -0,0 +1,6 @@ +subscription MessageDeletedSubscription($chatId: BigInt!) { + messageDeleted(chatId: $chatId) { + id + messageId + } +} diff --git a/poe/graphql/MessageFragment.graphql b/poe/graphql/MessageFragment.graphql new file mode 100644 index 00000000..cc860811 --- /dev/null +++ b/poe/graphql/MessageFragment.graphql @@ -0,0 +1,13 @@ +fragment MessageFragment on Message { + id + __typename + messageId + text + linkifiedText + authorNickname + state + vote + voteReason + creationTime + suggestedReplies +} diff --git a/poe/graphql/MessageRemoveVoteMutation.graphql b/poe/graphql/MessageRemoveVoteMutation.graphql new file mode 100644 index 00000000..d5e6e610 --- /dev/null +++ b/poe/graphql/MessageRemoveVoteMutation.graphql @@ -0,0 +1,7 @@ +mutation MessageRemoveVoteMutation($messageId: BigInt!) { + messageRemoveVote(messageId: $messageId) { + message { + ...MessageFragment + } + } +} diff --git a/poe/graphql/MessageSetVoteMutation.graphql b/poe/graphql/MessageSetVoteMutation.graphql new file mode 100644 index 00000000..76000df0 --- /dev/null +++ b/poe/graphql/MessageSetVoteMutation.graphql @@ -0,0 +1,7 @@ +mutation MessageSetVoteMutation($messageId: BigInt!, $voteType: VoteType!, $reason: String) { + messageSetVote(messageId: $messageId, voteType: $voteType, reason: $reason) { + message { + ...MessageFragment + } + } +} diff --git a/poe/graphql/SendVerificationCodeForLoginMutation.graphql b/poe/graphql/SendVerificationCodeForLoginMutation.graphql new file mode 100644 index 00000000..45af4799 --- /dev/null +++ b/poe/graphql/SendVerificationCodeForLoginMutation.graphql @@ -0,0 +1,12 @@ +mutation SendVerificationCodeForLoginMutation( + $emailAddress: String + $phoneNumber: String +) { + sendVerificationCode( + verificationReason: login + emailAddress: $emailAddress + phoneNumber: $phoneNumber + ) { + status + } +} diff --git a/poe/graphql/ShareMessagesMutation.graphql b/poe/graphql/ShareMessagesMutation.graphql new file mode 100644 index 00000000..92e80db5 --- /dev/null +++ b/poe/graphql/ShareMessagesMutation.graphql @@ -0,0 +1,9 @@ +mutation ShareMessagesMutation( + $chatId: BigInt! + $messageIds: [BigInt!]! + $comment: String +) { + messagesShare(chatId: $chatId, messageIds: $messageIds, comment: $comment) { + shareCode + } +} diff --git a/poe/graphql/SignupWithVerificationCodeMutation.graphql b/poe/graphql/SignupWithVerificationCodeMutation.graphql new file mode 100644 index 00000000..06b2826f --- /dev/null +++ b/poe/graphql/SignupWithVerificationCodeMutation.graphql @@ -0,0 +1,13 @@ +mutation SignupWithVerificationCodeMutation( + $verificationCode: String! + $emailAddress: String + $phoneNumber: String +) { + signupWithVerificationCode( + verificationCode: $verificationCode + emailAddress: $emailAddress + phoneNumber: $phoneNumber + ) { + status + } +} diff --git a/poe/graphql/StaleChatUpdateMutation.graphql b/poe/graphql/StaleChatUpdateMutation.graphql new file mode 100644 index 00000000..de203d47 --- /dev/null +++ b/poe/graphql/StaleChatUpdateMutation.graphql @@ -0,0 +1,7 @@ +mutation StaleChatUpdateMutation($chatId: BigInt!) { + staleChatUpdate(chatId: $chatId) { + message { + ...MessageFragment + } + } +} diff --git a/poe/graphql/SubscriptionsMutation.graphql b/poe/graphql/SubscriptionsMutation.graphql new file mode 100644 index 00000000..b864bd60 --- /dev/null +++ b/poe/graphql/SubscriptionsMutation.graphql @@ -0,0 +1,9 @@ +mutation subscriptionsMutation( + $subscriptions: [AutoSubscriptionQuery!]! +) { + autoSubscribe(subscriptions: $subscriptions) { + viewer { + id + } + } +} \ No newline at end of file diff --git a/poe/graphql/SummarizePlainPostQuery.graphql b/poe/graphql/SummarizePlainPostQuery.graphql new file mode 100644 index 00000000..afa2a84c --- /dev/null +++ b/poe/graphql/SummarizePlainPostQuery.graphql @@ -0,0 +1,3 @@ +query SummarizePlainPostQuery($comment: String!) { + summarizePlainPost(comment: $comment) +} diff --git a/poe/graphql/SummarizeQuotePostQuery.graphql b/poe/graphql/SummarizeQuotePostQuery.graphql new file mode 100644 index 00000000..5147c3c5 --- /dev/null +++ b/poe/graphql/SummarizeQuotePostQuery.graphql @@ -0,0 +1,3 @@ +query SummarizeQuotePostQuery($comment: String, $quotedPostId: BigInt!) { + summarizeQuotePost(comment: $comment, quotedPostId: $quotedPostId) +} diff --git a/poe/graphql/SummarizeSharePostQuery.graphql b/poe/graphql/SummarizeSharePostQuery.graphql new file mode 100644 index 00000000..cb4a623c --- /dev/null +++ b/poe/graphql/SummarizeSharePostQuery.graphql @@ -0,0 +1,3 @@ +query SummarizeSharePostQuery($comment: String!, $chatId: BigInt!, $messageIds: [BigInt!]!) { + summarizeSharePost(comment: $comment, chatId: $chatId, messageIds: $messageIds) +} diff --git a/poe/graphql/UserSnippetFragment.graphql b/poe/graphql/UserSnippetFragment.graphql new file mode 100644 index 00000000..17fc8426 --- /dev/null +++ b/poe/graphql/UserSnippetFragment.graphql @@ -0,0 +1,14 @@ +fragment UserSnippetFragment on PoeUser { + id + uid + bio + handle + fullName + viewerIsFollowing + isPoeOnlyUser + profilePhotoURLTiny: profilePhotoUrl(size: tiny) + profilePhotoURLSmall: profilePhotoUrl(size: small) + profilePhotoURLMedium: profilePhotoUrl(size: medium) + profilePhotoURLLarge: profilePhotoUrl(size: large) + isFollowable +} diff --git a/poe/graphql/ViewerInfoQuery.graphql b/poe/graphql/ViewerInfoQuery.graphql new file mode 100644 index 00000000..1ecaf9e8 --- /dev/null +++ b/poe/graphql/ViewerInfoQuery.graphql @@ -0,0 +1,21 @@ +query ViewerInfoQuery { + viewer { + id + uid + ...ViewerStateFragment + ...BioFragment + ...HandleFragment + hasCompletedMultiplayerNux + poeUser { + id + ...UserSnippetFragment + } + messageLimit{ + canSend + numMessagesRemaining + resetTime + shouldShowReminder + } + } +} + diff --git a/poe/graphql/ViewerStateFragment.graphql b/poe/graphql/ViewerStateFragment.graphql new file mode 100644 index 00000000..3cd83e9c --- /dev/null +++ b/poe/graphql/ViewerStateFragment.graphql @@ -0,0 +1,30 @@ +fragment ViewerStateFragment on Viewer { + id + __typename + iosMinSupportedVersion: integerGate(gateName: "poe_ios_min_supported_version") + iosMinEncouragedVersion: integerGate( + gateName: "poe_ios_min_encouraged_version" + ) + macosMinSupportedVersion: integerGate( + gateName: "poe_macos_min_supported_version" + ) + macosMinEncouragedVersion: integerGate( + gateName: "poe_macos_min_encouraged_version" + ) + showPoeDebugPanel: booleanGate(gateName: "poe_show_debug_panel") + enableCommunityFeed: booleanGate(gateName: "enable_poe_shares_feed") + linkifyText: booleanGate(gateName: "poe_linkify_response") + enableSuggestedReplies: booleanGate(gateName: "poe_suggested_replies") + removeInviteLimit: booleanGate(gateName: "poe_remove_invite_limit") + enableInAppPurchases: booleanGate(gateName: "poe_enable_in_app_purchases") + availableBots { + nickname + displayName + profilePicture + isDown + disclaimer + subtitle + poweredBy + } +} + diff --git a/poe/graphql/ViewerStateUpdatedSubscription.graphql b/poe/graphql/ViewerStateUpdatedSubscription.graphql new file mode 100644 index 00000000..03fc73d1 --- /dev/null +++ b/poe/graphql/ViewerStateUpdatedSubscription.graphql @@ -0,0 +1,43 @@ +subscription viewerStateUpdated { + viewerStateUpdated { + id + ...ChatPageBotSwitcher_viewer + } +} + +fragment BotHeader_bot on Bot { + displayName + messageLimit { + dailyLimit + } + ...BotImage_bot +} + +fragment BotImage_bot on Bot { + image { + __typename + ... on LocalBotImage { + localName + } + ... on UrlBotImage { + url + } + } + displayName +} + +fragment BotLink_bot on Bot { + displayName +} + +fragment ChatPageBotSwitcher_viewer on Viewer { + availableBots { + id + messageLimit { + dailyLimit + } + ...BotLink_bot + ...BotHeader_bot + } + allowUserCreatedBots: booleanGate(gateName: "enable_user_created_bots") +} diff --git a/poe/graphql/__init__.py b/poe/graphql/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/poe/mail.py b/poe/mail.py new file mode 100644 index 00000000..d4aeb564 --- /dev/null +++ b/poe/mail.py @@ -0,0 +1,62 @@ +from requests import Session +from string import ascii_letters +from random import choices + +class Mail: + def __init__(self, proxies: dict = None) -> None: + self.client = Session() + self.client.proxies = None #proxies + self.client.headers = { + "host": "api.mail.tm", + "connection": "keep-alive", + "sec-ch-ua": "\"Google Chrome\";v=\"111\", \"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"111\"", + "accept": "application/json, text/plain, */*", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36", + "sec-ch-ua-platform": "\"macOS\"", + "origin": "https://mail.tm", + "sec-fetch-site": "same-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + "referer": "https://mail.tm/", + "accept-encoding": "gzip, deflate, br", + "accept-language": "en-GB,en-US;q=0.9,en;q=0.8" + } + + def get_mail(self) -> str: + token = ''.join(choices(ascii_letters, k=10)).lower() + + init = self.client.post("https://api.mail.tm/accounts", json={ + "address" : f"{token}@bugfoo.com", + "password": token + }) + + if init.status_code == 201: + resp = self.client.post("https://api.mail.tm/token", json = { + **init.json(), + "password": token + }) + + self.client.headers['authorization'] = 'Bearer ' + resp.json()['token'] + + return f"{token}@bugfoo.com" + + else: + raise Exception("Failed to create email") + + def fetch_inbox(self): + return self.client.get(f"https://api.mail.tm/messages").json()["hydra:member"] + + def get_message(self, message_id: str): + return self.client.get(f"https://api.mail.tm/messages/{message_id}").json() + + def get_message_content(self, message_id: str): + return self.get_message(message_id)["text"] + + +# if __name__ == "__main__": +# client = Mail() +# client.get_mail() + + \ No newline at end of file -- cgit v1.2.3