├── MANIFEST.in ├── funkapi ├── __init__.py ├── graphql_schema.py └── api.py ├── .github └── FUNDING.yml ├── .gitignore ├── requirements.txt ├── setup.py ├── LICENSE.md └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.md 2 | -------------------------------------------------------------------------------- /funkapi/__init__.py: -------------------------------------------------------------------------------- 1 | from funkapi.api import FunkAPI -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ['lagmoellertim'] 4 | ko_fi: lagmoellertim 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | \.idea/ 3 | venv/ 4 | dist/ 5 | build/ 6 | funkapi\.egg-info/ 7 | 8 | test\.py 9 | 10 | funkapi/__pycache__/ 11 | freenet_funk_api\.egg-info/ 12 | \.vscode/ 13 | 14 | freenet_funk_api\.egg-info/ 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3==1.9.145 2 | botocore==1.12.145 3 | certifi==2022.12.7 4 | chardet==3.0.4 5 | docutils==0.14 6 | ecdsa==0.13.3 7 | envs==1.3 8 | future==0.17.1 9 | idna==2.8 10 | jmespath==0.9.4 11 | pycryptodome==3.8.1 12 | python-dateutil==2.8.0 13 | python-jose-cryptodome==1.3.2 14 | requests==2.21.0 15 | s3transfer==0.2.0 16 | six==1.12.0 17 | urllib3==1.26.5 18 | warrant==0.6.1 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as f: 4 | long_description = f.read() 5 | 6 | with open('requirements.txt') as f: 7 | requirements = f.read().splitlines() 8 | 9 | setuptools.setup( 10 | name='funkapi', 11 | version='0.1.7', 12 | install_requires=requirements, 13 | license='MIT License', 14 | author='Tim-Luca Lagmöller', 15 | author_email='hello@lagmoellertim.de', 16 | description='Reverse engineered API of Freenet FUNK', 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | url='https://github.com/lagmoellertim/freenet-funk-api', 20 | packages=setuptools.find_packages(), 21 | classifiers=[ 22 | "Programming Language :: Python :: 3", 23 | "License :: OSI Approved :: MIT License", 24 | "Operating System :: OS Independent", 25 | ], 26 | ) 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2019 Tim-Luca Lagmöller 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Freenet FUNK API 2 | 3 | ## Archived 4 | 5 | Since I don't use freenet funk anymore, I am not able to maintain this api any longer. If you want to become the new maintainer, feel free to fork this repo. 6 | 7 | ## Introduction 8 | 9 | Freenet FUNK is cellphone plan that offers **unlimited** (or 1 GB of) 4G data. The plan is can be started, stopped and paused **daily**. 10 | 11 | To make the most out of this flexibility, I reverse engineered the **API** to give anyone the ability to develop amazing apps on their own! 12 | 13 | ## Prerequisites 14 | 15 | - Python >=3.2 16 | - pip3 (or just pip on windows) 17 | 18 | ## Installation 19 | ### Install from pypi 20 | ```sh 21 | pip3 install funkapi 22 | ``` 23 | 24 | ### Install from source 25 | ```sh 26 | git clone https://github.com/lagmoellertim/freenet-funk-api.git 27 | 28 | cd freenet-funk-api 29 | 30 | pip3 install -r requirements.txt 31 | 32 | python3 setup.py install 33 | ``` 34 | 35 | ## Build 36 | 37 | ```sh 38 | git clone https://github.com/lagmoellertim/freenet-funk-api.git 39 | 40 | cd freenet-funk-api 41 | 42 | pip3 install -r requirements.txt 43 | 44 | python3 setup.py sdist bdist_wheel 45 | ``` 46 | 47 | ## Usage 48 | 49 | ### Initialize the API 50 | 51 | ```python3 52 | from funkapi import FunkAPI 53 | api = FunkAPI("*username*", "*password*") 54 | ``` 55 | 56 | ### Get a Token (not really necessary to do that manually but I left the option) 57 | 58 | ```python3 59 | token = api.getToken() 60 | ``` 61 | 62 | ### Initialize the API with a predefined Token 63 | 64 | ```python3 65 | from funkapi import FunkAPI 66 | api = FunkAPI("", "", token="*token*") 67 | ``` 68 | 69 | ### Check the validity of a Token (also not really necessary to do manually) 70 | 71 | ```python3 72 | isValid = api.testToken("*token*") 73 | ``` 74 | 75 | ### Get Dashboard Data (includes every piece of data FUNK stores of you) 76 | 77 | ```python3 78 | data = api.getData() 79 | ``` 80 | 81 | ### Get Personal Information (email, name, birthday, …) 82 | 83 | ```python3 84 | personalInfo = api.getPersonalInfo() 85 | ``` 86 | 87 | ### Get a List of your ordered Products 88 | 89 | ```python3 90 | products = api.getOrderedProducts() 91 | ``` 92 | 93 | ### Get the currently active Plan 94 | 95 | ```python3 96 | currentPlan = api.getCurrentPlan() 97 | ``` 98 | 99 | ### Order the 1GB Plan 100 | 101 | ```python3 102 | status = api.order1GBPlan() 103 | ``` 104 | 105 | ### Order the unlimited Plan 106 | 107 | ```python3 108 | status = api.orderUnlimitedPlan() 109 | ``` 110 | 111 | ### Start a Pause 112 | 113 | ```python3 114 | status = api.startPause() 115 | ``` 116 | 117 | ### Stop the latest Product (includes stopping a break) 118 | 119 | ```python3 120 | status = api.stopLatestPlan() 121 | ``` 122 | 123 | ### Documentation 124 | 125 | If you get stuck at some point while trying to use the API, take a look the code. It is fully commented and well-labeled, 126 | so that should help you understand what's going on. 127 | 128 | ## Contributing 129 | 130 | If you are missing a feature or have new idea, go for it! That is what open-source is for! 131 | 132 | ## Author 133 | 134 | **Tim-Luca Lagmöller** ([@lagmoellertim](https://github.com/lagmoellertim)) 135 | 136 | ## Donations / Sponsors 137 | 138 | I'm part of the official GitHub Sponsors program where you can support me on a monthly basis. 139 | 140 | GitHub Sponsors 141 | 142 | You can also contribute by buying me a coffee (this is a one-time donation). 143 | 144 | Ko-Fi Sponsors 145 | 146 | Thank you for your support! 147 | 148 | ## License 149 | 150 | [MIT License](https://github.com/lagmoellertim/cryption/blob/master/LICENSE) 151 | 152 | Copyright © 2019-present, [Tim-Luca Lagmöller](https://en.lagmoellertim.de) 153 | 154 | ## Have fun :tada: 155 | -------------------------------------------------------------------------------- /funkapi/graphql_schema.py: -------------------------------------------------------------------------------- 1 | schema = { 2 | "get_data":""" 3 | query CustomerForDashboardQuery { 4 | me { 5 | ...CustomerForDashboardFragment 6 | __typename 7 | } 8 | } 9 | 10 | fragment CustomerForDashboardFragment on Customer { 11 | id 12 | details { 13 | ...DetailsFragment 14 | __typename 15 | } 16 | customerProducts { 17 | ...ProductFragment 18 | __typename 19 | } 20 | __typename 21 | } 22 | 23 | fragment DetailsFragment on Details { 24 | firstName 25 | lastName 26 | dateOfBirth 27 | contactEmail 28 | __typename 29 | } 30 | 31 | fragment ProductFragment on FUNKCustomerProduct { 32 | id 33 | state 34 | paymentMethods { 35 | ...PaymentMethodFragment 36 | __typename 37 | } 38 | mobileNumbers { 39 | ...MobileNumberFragment 40 | __typename 41 | } 42 | sims { 43 | ...SIMFragment 44 | __typename 45 | } 46 | tariffs: tariffCustomerProductServices { 47 | ...TariffFragment 48 | __typename 49 | } 50 | __typename 51 | } 52 | 53 | fragment PaymentMethodFragment on PaymentMethod { 54 | id 55 | state 56 | approvalChallenge { 57 | approvalURL 58 | __typename 59 | } 60 | agreement { 61 | state 62 | payerInfo { 63 | payerID 64 | email 65 | __typename 66 | } 67 | __typename 68 | } 69 | __typename 70 | } 71 | 72 | fragment MobileNumberFragment on MobileNumberCPS { 73 | id 74 | number 75 | state 76 | usage { 77 | usedDataPercentage 78 | __typename 79 | } 80 | productServiceId 81 | productServiceInfo { 82 | id 83 | label 84 | __typename 85 | } 86 | ... on MNPImportCustomerProductService { 87 | otherProviderShortcut 88 | otherProviderCustomName 89 | otherContract { 90 | contractType 91 | mobileNumber 92 | mobileNumberIsVerified 93 | __typename 94 | } 95 | mnpInfos { 96 | confirmedPortingDate 97 | lastPortingResult 98 | problemCode 99 | problemReason 100 | __typename 101 | } 102 | __typename 103 | } 104 | __typename 105 | } 106 | 107 | fragment SIMFragment on SIMCustomerProductService { 108 | id 109 | networkState 110 | state 111 | iccid 112 | delivery { 113 | state 114 | trackingDetails { 115 | stateId 116 | stateLabel 117 | trackingURL 118 | __typename 119 | } 120 | deliveryProvider 121 | address { 122 | city 123 | additionalInfo 124 | __typename 125 | } 126 | __typename 127 | } 128 | __typename 129 | } 130 | 131 | fragment TariffFragment on TariffCustomerProductService { 132 | id 133 | booked 134 | starts 135 | state 136 | productServiceId 137 | productServiceInfo { 138 | id 139 | label 140 | follower { 141 | id 142 | label 143 | __typename 144 | } 145 | marketingInfo { 146 | name 147 | __typename 148 | } 149 | __typename 150 | } 151 | __typename 152 | } 153 | """, 154 | "order_plan":""" 155 | mutation AddTariffToProductMutation($productID: String!, $tariffID: String!) { 156 | tariffAddToCustomerProduct(customerProductId: $productID, productServiceId: $tariffID) { 157 | ...TariffFragment 158 | __typename 159 | } 160 | } 161 | 162 | fragment TariffFragment on TariffCustomerProductService { 163 | id 164 | booked 165 | starts 166 | state 167 | productServiceId 168 | productServiceInfo { 169 | id 170 | label 171 | follower { 172 | id 173 | label 174 | __typename 175 | } 176 | marketingInfo { 177 | name 178 | __typename 179 | } 180 | __typename 181 | } 182 | __typename 183 | } 184 | """, 185 | "remove_product":""" 186 | mutation TerminateTariffMutation($tariffID: String!) { 187 | tariffTerminate(customerProductServiceId: $tariffID) { 188 | ...TariffFragment 189 | __typename 190 | } 191 | } 192 | 193 | fragment TariffFragment on TariffCustomerProductService { 194 | id 195 | booked 196 | starts 197 | state 198 | productServiceId 199 | productServiceInfo { 200 | id 201 | label 202 | follower { 203 | id 204 | label 205 | __typename 206 | } 207 | marketingInfo { 208 | name 209 | __typename 210 | } 211 | __typename 212 | } 213 | __typename 214 | } 215 | """ 216 | } 217 | -------------------------------------------------------------------------------- /funkapi/api.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import requests 3 | import datetime 4 | from warrant.aws_srp import AWSSRP 5 | 6 | from funkapi.graphql_schema import schema 7 | 8 | class FunkAPI: 9 | """ 10 | Freenet FUNK API helper library 11 | """ 12 | 13 | API_ENDPOINT = "https://appapi.funk.services/" 14 | API_KEY = "FZ3OkFFfdMahh4a1xagOnaon39pUpml732kkb2Aw" 15 | AWS_REGION = "eu-central-1" 16 | AWS_POOL_ID = "eu-central-1_ZPDpzBJy4" 17 | AWS_CLIENT_ID = "3asd34f9vfrg6pd2mrbqhn3g3r" 18 | 19 | def __init__(self, username, password, token=None, always_test_token=False, 20 | ignore_token_check=False, ignore_token_retry=True, autoload_data=True): 21 | """ 22 | Initialise API. 23 | 24 | :param username: Account E-Mail 25 | :param password: Account Password 26 | :param token: AWS Cognito JWT token 27 | :param always_test_token: Whether the validity of a token should be tested after each request 28 | :param ignore_token_check: Whether a token should be checked at all (when supplied via the token param) 29 | :param ignore_token_retry: Whether to stop using the user/pass params if the supplied token is wrong 30 | :param autoload_data: Whether to automatically load the data on initialization 31 | """ 32 | 33 | self.username = username 34 | self.password = password 35 | self.always_test_token = always_test_token 36 | self.ignore_token_check = ignore_token_check 37 | self.ignore_token_retry = ignore_token_retry 38 | 39 | self.client = boto3.client('cognito-idp', region_name=self.AWS_REGION, 40 | aws_access_key_id="", aws_secret_access_key="") 41 | 42 | self.aws = AWSSRP(username=self.username, password=self.password, 43 | pool_id=self.AWS_POOL_ID, 44 | client_id=self.AWS_CLIENT_ID, client=self.client) 45 | 46 | self.token = None 47 | self.getToken(token=token) 48 | 49 | self.data = None 50 | if autoload_data: 51 | self.getData() 52 | 53 | def apiRequest(self, json, token=None): 54 | """ 55 | Trigger API endpoint and request data. 56 | 57 | :param json: graphQL request schema 58 | :param token: AWS cognito JWT token 59 | :return: API response 60 | """ 61 | 62 | token = token if token is not None else self.getToken() 63 | 64 | req = requests.post(self.API_ENDPOINT, json=json, 65 | headers={ 66 | "x-api-key": self.API_KEY, 67 | "Authorization": "Bearer " + token, 68 | "apollographql-client-version": "1.0.1 (1143)", 69 | "apollographql-client-name": "freenet FUNK iOS" 70 | }) 71 | return req.json() 72 | 73 | def getToken(self, refresh=False, token=None): 74 | """ 75 | Returns a valid aws cognito JWT token. 76 | 77 | :param refresh: Disallow caching 78 | :param token: AWS cognito JWT token 79 | :return: AWS cognito JWT token 80 | """ 81 | 82 | if token is not None: 83 | if self.testToken(token) or self.ignore_token_retry: 84 | self.token = token 85 | return self.token 86 | 87 | self.getToken(refresh=True) 88 | 89 | if self.token is None or refresh or ( 90 | False if not self.always_test_token else not self.testToken(self.token)): 91 | self.token = self.aws.authenticate_user()["AuthenticationResult"]["AccessToken"] 92 | 93 | return self.token 94 | 95 | def testToken(self, token): 96 | """ 97 | Test validity of authorization token. 98 | 99 | :param token: AWS cognito JWT token 100 | :return: Boolean whether the token is valid (True) or not (False) 101 | """ 102 | 103 | if token is None: 104 | return False 105 | 106 | if not self.ignore_token_check: 107 | json = {"operationName": "CustomerForDashboardQuery", "variables": {}, 108 | "query": "query CustomerForDashboardQuery { me { id } }"} 109 | 110 | result = self.apiRequest(json, token=token) 111 | 112 | if "errors" in result.keys(): 113 | return False 114 | 115 | return True 116 | 117 | def getData(self, refresh=False): 118 | """ 119 | Get data from endpoint. 120 | 121 | :param refresh: Ignore cache / Force Refresh 122 | :return: Data 123 | """ 124 | 125 | json = {"operationName": "CustomerForDashboardQuery", "variables": {}, 126 | "query": schema["get_data"]} 127 | if self.data is None or refresh: 128 | self.data = self.apiRequest(json) 129 | 130 | return self.data 131 | 132 | def getPersonalInfo(self, refresh_data=False): 133 | """ 134 | Get personal data. 135 | 136 | :param refresh_data: Ignore cache / Force Refresh 137 | :return: Personal data 138 | """ 139 | 140 | data = self.getData(refresh=refresh_data)["data"]["me"] 141 | personal_info = {"id": data["id"], **data["details"]} 142 | del personal_info["__typename"] 143 | 144 | return personal_info 145 | 146 | def getOrderedProducts(self, refresh_data=False): 147 | """ 148 | Get ordered products (e.g. sim cards). 149 | 150 | :param refresh_data: Ignore cache / Force Refresh 151 | :return: A list of all ordered Products 152 | """ 153 | 154 | return self.getData(refresh=refresh_data)["data"]["me"]["customerProducts"] 155 | 156 | def getCurrentPlan(self, refresh_data=False): 157 | """ 158 | Get current plan. 159 | 160 | :param refresh_data: Ignore cache / Force Refresh 161 | :return: Current plan 162 | """ 163 | 164 | now = datetime.datetime.now(datetime.timezone.utc) 165 | currentPlan = None 166 | for plan in self.getData(refresh=refresh_data)["data"]["me"]["customerProducts"][0]["tariffs"]: 167 | planStart = datetime.datetime.strptime(plan["starts"], "%Y-%m-%dT%H:%M:%S.%f%z") 168 | if planStart > now: 169 | continue 170 | currentPlan = plan 171 | 172 | return currentPlan 173 | 174 | def orderPlan(self, plan_id, product_id=None, refresh_data=True): 175 | """ 176 | Order a plan. 177 | 178 | :param plan_id: Id of plan which should be ordered 179 | :param product_id: The sim card id the plan should be applied to 180 | :param refresh_data: Ignore cache / Force Refresh 181 | :return: Result of order 182 | """ 183 | 184 | if product_id is None: 185 | product_id = self.getOrderedProducts()[0]["id"] 186 | 187 | json = {"operationName": "AddTariffToProductMutation", 188 | "variables": {"productID": product_id, "tariffID": str(plan_id)}, 189 | "query": schema["order_plan"]} 190 | result = self.apiRequest(json) 191 | 192 | self.getData(refresh=refresh_data) 193 | 194 | return result 195 | 196 | def removeProduct(self, personal_plan_id, refresh_data=True): 197 | """ 198 | Remove/Deactivate an active plan. 199 | 200 | :param personal_plan_id: Id of current plan 201 | :param refresh_data: Ignore cache / Force Refresh 202 | :return: Result of removal 203 | """ 204 | 205 | json = {"operationName": "TerminateTariffMutation", 206 | "variables": {"tariffID": personal_plan_id}, 207 | "query": schema["remove_product"]} 208 | 209 | result = self.apiRequest(json) 210 | 211 | self.getData(refresh=refresh_data) 212 | 213 | return result 214 | 215 | def order1GBPlan(self, **kwargs): 216 | """ 217 | Order 1GB plan. 218 | 219 | :param kwargs: More details at FunkAPI.orderPlan 220 | :return: Result of order 221 | """ 222 | 223 | return self.orderPlan(9, **kwargs) 224 | 225 | def orderUnlimitedPlan(self, **kwargs): 226 | """ 227 | Order unlimited plan. 228 | 229 | :param kwargs: More details at FunkAPI.orderPlan 230 | :return: Result of order 231 | """ 232 | 233 | return self.orderPlan(8, **kwargs) 234 | 235 | def startPause(self, **kwargs): 236 | """ 237 | Start pause mode. 238 | 239 | :param kwargs: More details at FunkAPI.orderPlan 240 | :return: Result of order 241 | """ 242 | 243 | return self.orderPlan(42, **kwargs) 244 | 245 | def stopLatestPlan(self, product_index=0, **kwargs): 246 | """ 247 | Stop current plan. 248 | 249 | :param product_index: The sim card id of which the plan should be stopped 250 | :param kwargs: More details at FunkAPI.removeProduct 251 | :return: Result of order 252 | """ 253 | 254 | personal_plan_id = self.getOrderedProducts()[product_index]["tariffs"][-1]["id"] 255 | return self.removeProduct(personal_plan_id, **kwargs) 256 | --------------------------------------------------------------------------------