├── .dockerignore ├── .editorconfig ├── .gitignore ├── .php_cs.php ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── ONB.csv ├── cellular.php ├── countrycodes.php └── washing.png ├── composer.json ├── config.example.php ├── docker-compose.yml ├── fbcallrouter ├── fbcallrouter.service └── src ├── ConfigTrait.php ├── RunCommand.php ├── callrouter ├── callmonitor.php ├── callrouter.php ├── dialercheck.php ├── infomail.php ├── logging.php └── phonetools.php └── functions.php /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .dockerignore 3 | .editorconfig 4 | .gitignore 5 | .php_cs.php 6 | .travis.yml 7 | config.php 8 | Dockerfile 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # PHP PSR-2 Coding Standards 5 | # http://www.php-fig.org/psr/psr-2/ 6 | 7 | root = true 8 | 9 | [*.php] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = space 15 | indent_size = 4 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /config.php 3 | /vendor/ 4 | /assets/phonebookerror.jpg 5 | /assets/phonebckgrnd.jpg 6 | /.vscode/launch.json 7 | /.vscode/settings.json 8 | -------------------------------------------------------------------------------- /.php_cs.php: -------------------------------------------------------------------------------- 1 | in('src') 5 | ; 6 | 7 | return PhpCsFixer\Config::create() 8 | ->setRules([ 9 | '@PSR2' => true, 10 | 'array_syntax' => ['syntax' => 'short'], 11 | ]) 12 | ->setFinder($finder) 13 | ->setUsingCache(false) 14 | ; -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: php 4 | 5 | cache: 6 | directories: 7 | - $HOME/.composer/cache 8 | 9 | install: 10 | - composer install 11 | 12 | matrix: 13 | fast_finish: true 14 | 15 | jobs: 16 | include: 17 | - stage: Test 18 | php: 7.1 19 | script: 20 | phpunit 21 | - stage: Static Analysis 22 | php: 7.1 23 | install: 24 | - rm -f ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini 25 | - composer require --dev "phpstan/phpstan" 26 | script: 27 | # Static analyzer check 28 | - ./vendor/bin/phpstan analyze --level=5 --no-progress src 29 | - stage: Code Style 30 | php: 7.1 31 | install: 32 | - rm -f ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini 33 | - composer require --dev "friendsofphp/php-cs-fixer:^2.8" 34 | script: 35 | # Check the code style 36 | - IFS=$'\n'; COMMIT_SCA_FILES=($(git diff --name-only --diff-filter=ACMRTUXB "${TRAVIS_COMMIT_RANGE}")); unset IFS 37 | - ./vendor/bin/php-cs-fixer fix --config=.php_cs.php -v --dry-run --diff --stop-on-violation --using-cache=no --path-mode=intersection -- "${COMMIT_SCA_FILES[@]}" 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.1-cli AS base 2 | WORKDIR /app 3 | 4 | RUN apt-get update && \ 5 | apt-get install -y \ 6 | libonig-dev \ 7 | libxml2-dev \ 8 | libcurl4-openssl-dev && \ 9 | apt-get purge -y --autoremove 10 | RUN docker-php-ext-install -j$(nproc) \ 11 | curl \ 12 | mbstring \ 13 | soap \ 14 | sockets \ 15 | xml 16 | 17 | FROM base AS build 18 | 19 | RUN apt-get update && \ 20 | apt-get install -y \ 21 | wget \ 22 | unzip 23 | 24 | COPY composer.json . 25 | # COPY composer.lock . 26 | RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer 27 | RUN composer install --no-dev --no-scripts 28 | 29 | COPY . . 30 | RUN composer dumpautoload --optimize 31 | 32 | RUN wget -O vorwahlen.zip "https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Nummerierung/Rufnummern/ONRufnr/Vorwahlverzeichnis_ONB.zip.zip?__blob=publicationFile&v=298" && \ 33 | unzip vorwahlen.zip && \ 34 | mv *.ONB.csv assets/ONB.csv && \ 35 | rm vorwahlen.zip 36 | 37 | FROM base AS final 38 | ENV DOCKER_CONTAINER=true 39 | 40 | COPY --from=build /app /app 41 | 42 | CMD ["php", "fbcallrouter", "run"] 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Black Senator 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # An extended call routing for AVM FRITZ!Box 2 | 3 | 4 | 5 | The program is **trying to identify spam calls**. So it is listen to the [FRITZ!Box](#disclaimer) call monitor and does several wash cycles to figure out whether it is spam or not. 6 | 7 | **Please remember to update the [master data](#master-data) regularly so that the program works correctly!** 8 | 9 | ## Release notes 10 | 11 | If you already use an older version please refer to the [update section](#update) and be aware that the **structure of the configuration file differ** (see [`config.example.php`](/config.example.php)). 12 | 13 | ## Preconditions 14 | 15 | * the program only works in the German telephone network! 16 | * you use a Fritz!Box for landline telephoning 17 | * you need to have a [separate spam phone book](https://avm.de/service/wissensdatenbank/dok/FRITZ-Box-7590/142_Rufsperren-fur-ankommende-und-ausgehende-Anrufe-z-B-0900-Nummern-einrichten/) beside your phone book! 18 | * you have a microcomputer (e.g. Raspberry Pi) running 24/7 in your network 19 | 20 | ## Description 21 | 22 | For an incoming call a cascaded check takes place: 23 | 24 | * First, it is checked, whether the number is **already known** in your telephone books (`'whitelist'`) and (`'blacklist'`) or (`'newlist'`). This also includes [central numbers](https://github.com/blacksenator/carddav2fb/wiki/Rufnummern-mit-wildcards) used, whose various extensions are defined with '*' in the phone book. Of course, all these known telephone numbers are not analyzed any further. 25 | 26 | * If unknown, it is checked if it is a **foreign number**. If you have set (`'blockForeign'`) the number will be direct transferred to the corresponding phone book (`'blacklist'`) for future rejections. If not, then foreign numbers are 27 | * screened to see whether they exceed or fall short of the expected lengths, 28 | * whether they transmit country codes that are not (or no longer) used or 29 | * if area code starts with invalid "0" (zero). 30 | 31 | * The following screenings are carried out for **domestic numbers**, which are essentially aimed at "CLIP - no screening": 32 | * transmission of a [**unvalid area code** ONB](#onb) or [cellular code](#rnb) 33 | * unvalid subscribers number, if a landline number starts with an zero 34 | * concealment of a mobile phone number using a prefix consisting of your own area code and country code, 35 | * transmission of the actual phone number in brackets after a user provided number and 36 | * falling below or exceeding expected lengths. 37 | 38 | * If all this passed, it is checked at various scoring sites (currently five: [WERRUFT](https://www.werruft.info), [Clever Dialer](https://www.cleverdialer.de), [Telefonspion](https://www.telefonspion.de/), [WerHatAngerufen](https://www.werhatangerufen.com) and [tellows](https://www.tellows.de/)), if this number has received a **bad online rating**. If this is the case, the number - also as with pattern errors - is included in the blacklist. 39 | 40 | * Finally, of course, there is the possibility that the caller is known in a positive sense and can be identified via a public telephone book ([Das Örtliche](https://www.dasoertliche.de/rueckwaertssuche/)). Then he/she/it is optionally entered in a dedicated phone book (`'newlist'`) with the determined name. 41 | **This feature is also used for outgoing calls to unknown numbers!** 42 | 43 | * In addition, the program offers the option of being **informed** about unknown incoming (and outgoing) phone numbers **by email**. In particular, the information on the extent to which the caller could be identified and possibly added to one of the telephone books is convinient without having to be able to trace this using the caller list in the FRITZ!Box or in the logging file. 44 | 45 | ## Requirements 46 | 47 | * PHP >= 7.3 (php-cli, php-curl, php-mbstring, php-soap, php-xml) 48 | * call monitor (port 1012) is open - if not: dial `#96*5*` to open it 49 | * Composer (follow the [installation guide](https://getcomposer.org/download/)) 50 | 51 | ## Installation 52 | 53 | ### Programm 54 | 55 | Install requirements are: 56 | 57 | ```console 58 | git clone https://github.com/blacksenator/fbcallrouter.git 59 | cd fbcallrouter 60 | ``` 61 | 62 | Install composer (see for newer instructions): 63 | 64 | ```console 65 | composer install --no-dev 66 | ``` 67 | 68 | [Edit](#configuration) the `config.example.php` and save as `config.php` or use an other name of your choice (but than keep in mind to use the -c option to define your renamed file) 69 | 70 | ### Preconditions on the FRITZ!Box 71 | 72 | #### Phone book for rejections (bad apples) 73 | 74 | If you do not have your own phone book for spam numbers, it is essential to add a new one (e.g. "Spamnummern"). Note that the first phone book ("Telefonbuch") has the index "0" and the numbers are ascending according to the index tabs. 75 | Then you have to [link this phone book for call handling](https://avm.de/service/wissensdatenbank/dok/FRITZ-Box-7590/142_Rufsperren-fur-ankommende-und-ausgehende-Anrufe-z-B-0900-Nummern-einrichten/). 76 | 77 | #### Phone book for trustworthy new numbers 78 | 79 | If you want to add previously unknown but trustworthy umbers as contacts, you can do this optionally: e.g. in a separate phone book that you have created in the FRITZ!Box. However, you can also have these additions added to your standard telephone book.. 80 | 81 | #### FRITZ!Box user 82 | 83 | The programm accessed the Fritz!Box via TR-064. Make sure that the user you choose in configuration (see [next topic](#configuration)) is granted for this interface. The user needs authorization for "voice messages, fax messages, FRITZ!App Fon and the call list"! 84 | 85 | ### Configuration 86 | 87 | The configuration file (default `config.php`) offers various customization options. For example: Adapted to the tellows rating model a bad score is determined as six to nine and needs at least more than three comments. The second parameter is for quality purposes: not a single opinion should block a service provider whose call you might expect. 88 | You can adapt the values in the configuration file. **But be carefull! If you choose score values equal or smaler then five (5), than numbers with impeccable reputation where written to your rejection list!** 89 | Because the rating websites use different scales, they are normalized to the tellows scale. 90 | 91 | If you set `'log'` in your configuration and the `'logPath'` is valid, the essential process steps for verification are written to the log file `callrouter_logging.txt`. If `'logPath'` is empty the programm directory is default. 92 | 93 | ## Usage 94 | 95 | As mentioned, the program is designed to run permanently in the background. In order to ensure this, it is necessary to make sure that this is possible without interruption and should be tested accordingly. 96 | 97 | ### Test 98 | 99 | It is highly recommended test the setting by following the next steps: 100 | 101 | #### 1. Function test 102 | 103 | ```console 104 | php fbcallrouter run 105 | ``` 106 | 107 | If `On guard...` appears, the program is armed. 108 | 109 | Make a function test in which you call your landline from your cell phone: your mobile phone number **should not** end up in the spam phone book! 110 | If the number is in your phone book, nothing should happen anyway (on whitelist). 111 | Otherwise: your mobile phone number is not a foreign number, the [ONB](#onb)/[RNB](#rnb) is correct and you certainly do not have a bad entry in online directories. Therefore these tests should not lead to any sorting out. 112 | Press `CTRL+C` to terminate the programm. 113 | 114 | If logging is enabled (which is highly recommended for this test), than `nano callrouter_logging.txt` will show you what happend at the call monitor interface. 115 | 116 | #### 2. Integration test 117 | 118 | There are five exemplary `'numbers'` stored in the configuration file with which you can test the wash cycles integratively. You can change these test numbers according to your own ideas and quantity. Starting the programm with the `-t` option, these numbers will be injected as substitutes one after the other for the next calls the FRITZ!Box receives and its call monitor port will broadcast. 119 | 120 | It is highly recommended to proceed like this: 121 | 122 | 1. check if none of the substitutes are already in your phone book! **Especially if you repeat the test!** 123 | 124 | 2. if you want to a web query, make crosscheck at the named providers that the choosen test phone number(s) actually **has/ve the desired score and comments!** 125 | 126 | 3. start the programm 127 | 128 | ```console 129 | php fbcallrouter run -t 130 | ``` 131 | 132 | 4. use your cellular phone to call your landline. The incoming mobil number will be replaced with the first/next number from this array and passes through the inspection process. Additional information is output like this: 133 | 134 | ```console 135 | Starting FRITZ!Box call router... 136 | On guard... 137 | Running test case 1/5 138 | ``` 139 | 140 | 5. let it **ring at least twice** before hanging up. Repeat calling from your cellular your landline number. So you have to call as many times as there are numbers in the array to check all test cases (or quit programm execution with `CTRL+C`). The program then ends this number replacement. 141 | 142 | 6. check the blacklist phone book whether all numbers have been entered as expected. If logging is enabled, than `nano callrouter_logging.txt` will show you what happend. If e-mail notification is choosen check your inbox. 143 | To cancel, press `CTRL+C`. 144 | 145 | ### 3. Permanent background processing 146 | 147 | The **main concept** of this tool is to **continuously check incoming calls**. Therefore it should ideally run permanently as a background process on a single-board computer (e.g. Raspberry Pi) in the home network. A corresponding definition file is prepared: [`fbcallrouter.service`](/fbcallrouter.service) 148 | 149 | 1. edit `[youruser]` in this file with your device user (e.g. `pi`) and save the file 150 | 151 | ```console 152 | nano fbcallrouter.service 153 | ``` 154 | 155 | 2. copy the file into `/etc/systemd/system` 156 | 157 | ```console 158 | sudo cp fbcallrouter.service /etc/systemd/system/fbcallrouter.service 159 | ``` 160 | 161 | 3. enable the service unit: 162 | 163 | ```console 164 | sudo systemctl enable fbcallrouter.service 165 | ``` 166 | 167 | 4. check the status: 168 | 169 | ```console 170 | sudo systemctl status fbcallrouter.service 171 | ``` 172 | 173 | ## Update 174 | 175 | As noted at the beginning, functional enhancements usually go hand in hand with additions to the configuration file. To install the current version, please proceed as follows: 176 | 177 | 1. change to the installation directory: 178 | 179 | ```console 180 | cd /home/[youruser]/fbcallrouter 181 | ``` 182 | 183 | 2. stop the service: 184 | 185 | ```console 186 | sudo systemctl stop fbcallrouter.service 187 | ``` 188 | 189 | 3. delete the old logging file (if you used it here): 190 | 191 | ```console 192 | rm callrouter_logging.txt 193 | ``` 194 | 195 | 4. get the latest version from: 196 | 197 | ```console 198 | git pull https://github.com/blacksenator/fbcallrouter.git 199 | ``` 200 | 201 | 5. bring all used libraries up to date: 202 | 203 | ```console 204 | composer update --no-dev 205 | ``` 206 | 207 | 6. check for changes in the configuration file... 208 | 209 | ```console 210 | nano config.example.php 211 | ``` 212 | 213 | 7. ...and eventually make necessary changes/additions in your configuration: 214 | 215 | ```console 216 | nano config.example.php 217 | ``` 218 | 219 | 8. restart the service... 220 | 221 | ```console 222 | sudo systemctl start fbcallrouter.service 223 | ``` 224 | 225 | 9. ...and wait few seconds before you check if the service is running: 226 | 227 | ```console 228 | sudo systemctl status fbcallrouter.service 229 | ``` 230 | 231 | ### Master data 232 | 233 | #### ONB 234 | 235 | ONB = OrtsNetzBereiche (Vorwahlbereiche/Vorwahlen). The list used comes from the [BNetzA](https://www.bundesnetzagentur.de/DE/Fachthemen/Telekommunikation/Nummerierung/ONRufnr/ortsnetze_node.html) and should be valid for a limited period of time. If you want to update them, then download the offered **CSV file** [Vorwahlverzeichnis](https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Nummerierung/Rufnummern/ONRufnr/Vorwahlverzeichnis_ONB.zip.zip?__blob=publicationFile&v=298). Unpack the archive and save the file as `ONB.csv` in the `./assets` directory. 236 | 237 | #### RNB 238 | 239 | RNB = numbers for mobile services. The BNetzA do not provide a list for download with cellular codes [(RNB)](https://www.bundesnetzagentur.de/DE/Fachthemen/Telekommunikation/Nummerierung/MobileDienste/start.html). The currently used ones were transferred to the `./assets` directory as `$cellularNumber` in `cellular.php`. 240 | 241 | #### Country codes 242 | 243 | The currently used ones were transferred to the `./assets` directory as `$countryCode` in `countrycode.php`. The list was mainly compiled from information from [wikipedia](https://de.wikipedia.org/wiki/L%C3%A4ndervorwahlliste_sortiert_nach_Nummern). 244 | 245 | ## Does the programm works propperly? Troubleshooting 246 | 247 | First of all: of course the program can contain bugs and not work as expected. If you are convinced, please open an issue here. 248 | In addition, it can of course be more due to the selected settings for the configuration of the `filter`. 249 | 250 | Last but not least I myself have made some observations that led me to suspect that the program would not work correctly. In principle, it is advisable in such cases to **switch on logging** (`'log' => true,`) or to compare the active logging with the call list of the FRITZ!Box. You may need to go a step further and correlate dates with reverse search sites. 251 | 252 | An example: 253 | I had calls that should have been identified as spam at first glance through web research. A closer look showed that this spammer only appeared in the lists at exactly that time and had not yet received a sufficient number of negative comments at the time of the call. 254 | 255 | ## Privacy 256 | 257 | No private data from this software will be passed on to third parties accepts with this exception: 258 | when using 259 | 260 | * [WERRUFT](https://www.werruft.info/bedingungen/), 261 | * [Clever Dialer](https://www.cleverdialer.de/datenschutzerklaerung-website) 262 | * [Telefonspion](https://www.telefonspion.de/datenschutz.php) 263 | * [WerHatAngerufen](https://www.werhatangerufen.com/terms) 264 | * [tellows](https://www.tellows.de/c/about-tellows-de/datenschutz/) 265 | * [Das Örtliche](https://www.dasoertliche.de/datenschutz) 266 | 267 | the incoming number is transmitted to the provider. Their data protection information must be observed! 268 | 269 | ## Feedback 270 | 271 | If you enjoy this software, then I would be happy to receive your feedback, also in the form of user comments and descriptions of your experiences, e.g. in the [IP Phone Forum](https://www.ip-phone-forum.de/threads/fbcallrouter-extended-call-routing-spam-filter-for-fritz-box.303211/). This puts the user community on a broader basis and their experiences and functional ideas can be incorporated into further development. In the end, these features will benefit everyone. 272 | 273 | ## Improvements 274 | 275 | As ever, this program [started with only a few lines of code](https://www.ip-phone-forum.de/threads/howto-werbeanrufe-automatisch-beenden-lassen-mit-freetz.298448/post-2309645) just to play with the call monitor interface provided and to figure out how it works... 276 | 277 | ...than more and more ideas came to my mind how this interface could solve some of my needs. 278 | 279 | As I have already written in the [fritzsoap documentation](https://github.com/blacksenator/fritzsoap#wishes), it would be an enormous relief **if AVM would provide functionality to terminate incoming calls**, just as FRITZ!OS itself does it with the handling of phone numbers to be blocked. The entry of more and more numbers in corresponding blacklists could be omitted or reduced. 280 | 281 | ## Disclaimer 282 | 283 | FRITZ!Box, FRITZ!fon, FRITZ!OS are trademarks of [AVM](https://avm.de/). This software is **in no way affiliated** with AVM and only uses the [interfaces published by them](https://avm.de/service/schnittstellen/). 284 | 285 | ## License 286 | 287 | This script is released under MIT license. 288 | 289 | ## Author 290 | 291 | Copyright© 2019 - 2024 Volker Püschel 292 | 293 | 294 | [def]: #master-data -------------------------------------------------------------------------------- /assets/cellular.php: -------------------------------------------------------------------------------- 1 | 'Tismi BV', 16 | '15020' => 'Legos', 17 | // '151' => 'Telekom', reserviert 18 | '1511' => 'Telekom', 19 | '1512' => 'Telekom', 20 | '1514' => 'Telekom', 21 | '1515' => 'Telekom', 22 | '1516' => 'Telekom', 23 | '1517' => 'Telekom', 24 | '15180' => 'Telekom', 25 | '15181' => 'Telekom', 26 | // '152' => 'Vodafone', reserviert 27 | '1520' => 'Vodafone', 28 | '1521' => 'Lycamobile', 29 | '1522' => 'Vodafone', 30 | '1523' => 'Vodafone', 31 | '1525' => 'Vodafone', 32 | '1526' => 'Vodafone', 33 | '1529' => 'Vodafone', 34 | '15310' => 'MTEL', 35 | '15510' => 'Lebara', 36 | '15511' => 'Lebara', 37 | '15560' => 'Lebara', 38 | '15561' => 'Lebara', 39 | '15562' => '1&1', 40 | '15563' => '1&1', 41 | '15564' => '1&1', 42 | '15565' => '1&1', 43 | '15566' => '1&1', 44 | '15567' => '1&1', 45 | '15568' => '1&1', 46 | '15569' => '1&1', 47 | '15630' => 'multiConnect', 48 | '15678' => 'Argon', 49 | '15679' => 'Argon', 50 | // '157' => 'E-Plus', reserviert 51 | '15700' => 'Telefónica', 52 | '15701' => 'Telefónica', 53 | '15702' => 'Telefónica', 54 | '15703' => 'Telefónica', 55 | '15704' => 'Telefónica', 56 | '15706' => 'Telefónica', 57 | '1573' => 'Telefónica', 58 | '1575' => 'Telefónica', 59 | '1577' => 'Telefónica', 60 | '1578' => 'Telefónica', 61 | '1579' => 'Telefónica/SipGate', 62 | '15888' => 'TelcoVillage', 63 | // '159' => 'Telefónica', reserviert 64 | '1590' => 'Telefónica', 65 | '160' => 'Telekom', 66 | '162' => 'Vodafone', 67 | '163' => 'Telefónica', 68 | '170' => 'Telekom', 69 | '171' => 'Telekom', 70 | '172' => 'Vodafone', 71 | '173' => 'Vodafone', 72 | '174' => 'Vodafone', 73 | '175' => 'Telekom', 74 | '176' => 'Telefónica', 75 | '177' => 'Telefónica', 76 | '178' => 'Telefónica', 77 | '179' => 'Telefónica', 78 | ]; 79 | -------------------------------------------------------------------------------- /assets/countrycodes.php: -------------------------------------------------------------------------------- 1 | 'USA', 16 | '1204' => 'Kanada', 17 | '1226' => 'Kanada', 18 | '1236' => 'Kanada', 19 | '1242' => 'Bahamas', 20 | '1246' => 'Barbados', 21 | '1249' => 'Kanada', 22 | '1250' => 'Kanada', 23 | '1264' => 'Anguilla', 24 | '1268' => 'Antigua/Barbuda', 25 | '1284' => 'Britische Jungferninseln', 26 | '1289' => 'Kanada', 27 | '1306' => 'Kanada', 28 | '1340' => 'Amerikanische Jungferninseln', 29 | '1343' => 'Kanada', 30 | '1345' => 'Kaimaninseln', 31 | '1365' => 'Kanada', 32 | '1403' => 'Kanada', 33 | '1416' => 'Kanada', 34 | '1418' => 'Kanada', 35 | '1431' => 'Kanada', 36 | '1437' => 'Kanada', 37 | '1438' => 'Kanada', 38 | '1441' => 'Bermuda', 39 | '1450' => 'Kanada', 40 | '1473' => 'Grenada', 41 | '1506' => 'Kanada', 42 | '1514' => 'Kanada', 43 | '1519' => 'Kanada', 44 | '1579' => 'Kanada', 45 | '1581' => 'Kanada', 46 | '1587' => 'Kanada', 47 | '1604' => 'Kanada', 48 | '1613' => 'Kanada', 49 | '1639' => 'Kanada', 50 | '1647' => 'Kanada', 51 | '1649' => 'Turks-/Caicosinseln', 52 | '1664' => 'Montserrat', 53 | '1670' => 'Nördliche Marianen', 54 | '1671' => 'Guam', 55 | '1684' => 'Amerikanisch-Samoa', 56 | '1705' => 'Kanada', 57 | '1709' => 'Kanada', 58 | '1721' => 'Sint Maarten', 59 | '1758' => 'St. Lucia', 60 | '1767' => 'Dominica', 61 | '1778' => 'Kanada', 62 | '1780' => 'Kanada', 63 | '1782' => 'Kanada', 64 | '1784' => 'St. Vincent/Grenadinen', 65 | '1807' => 'Kanada', 66 | '1808' => 'Midwayinseln', 67 | '1819' => 'Kanada', 68 | '1829' => 'Dominikanische Republik', 69 | '1867' => 'Kanada', 70 | '1868' => 'Trinidad/Tobago', 71 | '1869' => 'St. Kitts/Nevis', 72 | '1873' => 'Kanada', 73 | '1876' => 'Jamaika', 74 | '1902' => 'Kanada', 75 | '1905' => 'Kanada', 76 | '1939' => 'Puerto Rico', 77 | '20' => 'Ägypten', 78 | '211' => 'Südsudan', 79 | '212' => 'Marokko', 80 | '213' => 'Algerien', 81 | '216' => 'Tunesien', 82 | '218' => 'Libyen', 83 | '220' => 'Gambia', 84 | '221' => 'Senegal', 85 | '222' => 'Mauretanien', 86 | '223' => 'Mali', 87 | '224' => 'Guinea', 88 | '225' => 'Côte d‘Ivoire', 89 | '226' => 'Burkina Faso (Obervolta)', 90 | '227' => 'Niger', 91 | '228' => 'Togo', 92 | '229' => 'Benin', 93 | '230' => 'Mauritius', 94 | '231' => 'Liberia', 95 | '232' => 'Sierra Leone', 96 | '233' => 'Ghana', 97 | '234' => 'Nigeria', 98 | '235' => 'Tschad', 99 | '236' => 'Zentralafrikanische Republik', 100 | '237' => 'Kamerun', 101 | '238' => 'Kap Verde', 102 | '239' => 'São Tomé/Príncipe', 103 | '240' => 'Äquatorialguinea', 104 | '241' => 'Gabun', 105 | '242' => 'Republik Kongo (Brazzaville)', 106 | '243' => 'Kongo', 107 | '244' => 'Angola', 108 | '245' => 'Guinea-Bissau', 109 | '246' => 'Chagos-Archipel (Diego-Garcia)', 110 | '247' => 'Ascension', 111 | '248' => 'Seychellen', 112 | '249' => 'Sudan', 113 | '250' => 'Ruanda', 114 | '251' => 'Äthiopien', 115 | '252' => 'Somalia', 116 | '253' => 'Dschibuti', 117 | '254' => 'Kenia', 118 | '255' => 'Tansania', 119 | '256' => 'Uganda', 120 | '257' => 'Burundi', 121 | '258' => 'Mosambik', 122 | '260' => 'Sambia', 123 | '261' => 'Madagaskar', 124 | '262' => 'Réunion und Mayotte', 125 | '263' => 'Simbabwe', 126 | '264' => 'Namibia', 127 | '265' => 'Malawi', 128 | '266' => 'Lesotho', 129 | '267' => 'Botswana', 130 | '268' => 'Swasiland', 131 | '269' => 'Komoren', 132 | '27' => 'Südafrika', 133 | '290' => 'St. Helena/Tristan da Cunha', 134 | '291' => 'Eritrea', 135 | '297' => 'Aruba', 136 | '298' => 'Färöer,', 137 | '299' => 'Grönland,', 138 | '30' => 'Griechenland', 139 | '31' => 'Niederlande', 140 | '32' => 'Belgien', 141 | '33' => 'Frankreich', 142 | '34' => 'Spanien', 143 | '350' => 'Gibraltar', 144 | '351' => 'Portugal', 145 | '352' => 'Luxemburg', 146 | '353' => 'Irland', 147 | '354' => 'Island', 148 | '355' => 'Albanien', 149 | '356' => 'Malta', 150 | '357' => 'Zypern', 151 | '358' => 'Finnland', 152 | '359' => 'Bulgarien', 153 | '36' => 'Ungarn', 154 | '370' => 'Litauen', 155 | '371' => 'Lettland', 156 | '372' => 'Estland', 157 | '373' => 'Moldawien', 158 | '374' => 'Armenien', 159 | '375' => 'Weißrussland', 160 | '376' => 'Andorra', 161 | '377' => 'Monaco', 162 | '378' => 'San Marino', 163 | '379' => 'Vatikanstadt', 164 | '380' => 'Ukraine', // stand with Ukraine 165 | '381' => 'Serbien', 166 | '382' => 'Montenegro', 167 | '383' => 'Kosovo', 168 | '385' => 'Kroatien', 169 | '386' => 'Slowenien', 170 | '387' => 'Bosnien Herzegowina', 171 | '389' => 'Mazedonien', 172 | '39' => 'Italien', 173 | '40' => 'Rumänien', 174 | '41' => 'Schweiz', 175 | '420' => 'Tschechien', 176 | '421' => 'Slowakei', 177 | '423' => 'Liechtenstein', 178 | '43' => 'Österreich', 179 | '44' => 'Vereinigtes Königreich/Isle of Man/Kanalinseln', 180 | '45' => 'Dänemark', 181 | '46' => 'Schweden', 182 | '47' => 'Norwegen', 183 | '48' => 'Polen', 184 | '49' => 'Deutschland', 185 | '500' => 'Falklandinseln', 186 | '501' => 'Belize', 187 | '502' => 'Guatemala', 188 | '503' => 'El Salvador', 189 | '504' => 'Honduras', 190 | '505' => 'Nicaragua', 191 | '506' => 'Costa Rica', 192 | '507' => 'Panama', 193 | '508' => 'Saint-Pierre und Miquelon', 194 | '509' => 'Haiti', 195 | '51' => 'Peru', 196 | '52' => 'Mexiko', 197 | '53' => 'Kuba', 198 | '54' => 'Argentinien', 199 | '55' => 'Brasilien', 200 | '56' => 'Chile', 201 | '57' => 'Kolumbien', 202 | '58' => 'Venezuela', 203 | '590' => 'Guadeloupe', 204 | '591' => 'Bolivien', 205 | '592' => 'Guyana', 206 | '593' => 'Ecuador', 207 | '594' => 'Französisch-Guayana', 208 | '595' => 'Paraguay', 209 | '596' => 'Martinique', 210 | '597' => 'Suriname', 211 | '598' => 'Uruguay', 212 | '599' => 'Bonaire/Curaçao/Sint Marteen/Saba/Sint Eustatius', 213 | '60' => 'Malaysia', 214 | '61' => 'Australien', 215 | '62' => 'Indonesien', 216 | '63' => 'Philippinen', 217 | '64' => 'Neuseeland', 218 | '65' => 'Singapur', 219 | '66' => 'Thailand', 220 | '670' => 'Osttimor', 221 | '672' => 'Antarktis/Norfolkinsel', 222 | '673' => 'Brunei', 223 | '674' => 'Nauru', 224 | '675' => 'Papua-Neuguinea', 225 | '676' => 'Tonga', 226 | '677' => 'Salomonen', 227 | '678' => 'Vanuatu', 228 | '679' => 'Fidschi', 229 | '680' => 'Palau', 230 | '681' => 'Wallis/Futuna', 231 | '682' => 'Cookinseln', 232 | '683' => 'Niue', 233 | '685' => 'Samoa', 234 | '686' => 'Kiribati, Gilbertinseln', 235 | '687' => 'Neukaledonien', 236 | '688' => 'Tuvalu, Elliceinseln', 237 | '689' => 'Französisch-Polynesien', 238 | '690' => 'Tokelau', 239 | '691' => 'Mikronesien', 240 | '692' => 'Marshallinseln', 241 | '730' => 'Russland', 242 | '734' => 'Russland', 243 | '735' => 'Russland', 244 | '7365' => 'Autonome Republik Krim (Ukraine)', // stand with Ukraine 245 | '738' => 'Russland', 246 | '739' => 'Russland', 247 | '740' => 'Russland', 248 | '741' => 'Russland', 249 | '742' => 'Russland', 250 | '747' => 'Russland', 251 | '748' => 'Russland', 252 | '749' => 'Russland', 253 | '77' => 'Kasachstan', 254 | '781' => 'Russland', 255 | '782' => 'Russland', 256 | '783' => 'Russland', 257 | '7840' => 'Georgien (Abchasien)', 258 | '7841' => 'Russland', 259 | '7842' => 'Russland', 260 | '7843' => 'Russland', 261 | '7844' => 'Russland', 262 | '7845' => 'Russland', 263 | '7846' => 'Russland', 264 | '7847' => 'Russland', 265 | '7848' => 'Russland', 266 | '7849' => 'Russland', 267 | '785' => 'Russland', 268 | '7860' => 'Russland', 269 | '7861' => 'Russland', 270 | '7862' => 'Russland', 271 | '7863' => 'Russland', 272 | '7864' => 'Russland', 273 | '7865' => 'Russland', 274 | '7866' => 'Russland', 275 | '7867' => 'Russland', 276 | '7868' => 'Russland', 277 | '7869' => 'Autonome Republik Krim (Ukraine)', // stand with Ukraine 278 | '787' => 'Russland', 279 | '790' => 'Russland', 280 | '791' => 'Russland', 281 | '792' => 'Russland', 282 | '793' => 'Russland', 283 | '7940' => 'Georgien (Abchasien)', 284 | '7941' => 'Russland', 285 | '7942' => 'Russland', 286 | '7943' => 'Russland', 287 | '7944' => 'Russland', 288 | '7945' => 'Russland', 289 | '7946' => 'Russland', 290 | '7947' => 'Russland', 291 | '7948' => 'Russland', 292 | '795' => 'Russland', 293 | '796' => 'Russland', 294 | '7970' => 'Russland', 295 | '7971' => 'Russland', 296 | '7972' => 'Russland', 297 | '7973' => 'Russland', 298 | '7974' => 'Russland', 299 | '7975' => 'Russland', 300 | '7976' => 'Russland', 301 | '7978' => 'Autonome Republik Krim (Ukraine)', // stand with Ukraine 302 | '7979' => 'Russland', 303 | '800' => 'Internationale Free-Phone-Dienste', 304 | '81' => 'Japan', 305 | '82' => 'Südkorea', 306 | '84' => 'Vietnam', 307 | '850' => 'Nordkorea', 308 | '852' => 'Hongkong', 309 | '853' => 'Macao', 310 | '855' => 'Kambodscha', 311 | '856' => 'Laos', 312 | '86' => 'Volksrepublik China', 313 | '870' => 'Inmarsat Single Number Access (SNAC)', 314 | '876' => 'maritime Mobiltelefonie', 315 | '878' => 'Persönliche Rufnummern', 316 | '879' => 'nationale mobile bezw. maritime Aufgaben', 317 | '880' => 'Bangladesch', 318 | '881' => 'Globales mobiles Satellitensystem', 319 | '882' => 'Internationale Netzwerke', 320 | '883' => 'Internationale Netzwerke (iNum)', 321 | '886' => 'Taiwan', 322 | '888' => 'Telecommunications for Disaster Relief', 323 | '90' => 'Türkei/Nordzypern', 324 | '91' => 'Indien', 325 | '92' => 'Pakistan', 326 | '93' => 'Afghanistan', 327 | '94' => 'Sri Lanka', 328 | '95' => 'Myanmar', 329 | '960' => 'Malediven', 330 | '961' => 'Libanon', 331 | '962' => 'Jordanien', 332 | '963' => 'Syrien', 333 | '964' => 'Irak', 334 | '965' => 'Kuwait', 335 | '966' => 'Saudi-Arabien', 336 | '967' => 'Jemen', 337 | '968' => 'Oman', 338 | '970' => 'Palästinensische Autonomiegebiete', 339 | '971' => 'Vereinigte Arabische Emirate', 340 | '972' => 'Israel', 341 | '973' => 'Bahrain', 342 | '974' => 'Katar', 343 | '975' => 'Bhutan', 344 | '976' => 'Mongolei', 345 | '977' => 'Nepal', 346 | '979' => 'Internationale Premium-Rate-Dienste', 347 | '98' => 'Iran', 348 | '991' => 'Service Trials (ITPCS)', 349 | '992' => 'Tadschikistan', 350 | '993' => 'Turkmenistan', 351 | '994' => 'Aserbaidschan', 352 | '995' => 'Georgien', 353 | '996' => 'Kirgisistan', 354 | '998' => 'Usbekistan', 355 | ]; 356 | -------------------------------------------------------------------------------- /assets/washing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blacksenator/fbcallrouter/5f934c591fc252edadd71720a04b231ab8b2630b/assets/washing.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blacksenator/fbcallrouter", 3 | "type": "project", 4 | "description": "", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "blacksenator", 9 | "email": "knuffy@anasco.de" 10 | } 11 | ], 12 | "require": { 13 | "php": "^7.3 || ^8.0", 14 | "symfony/console": "^5.4", 15 | "guzzlehttp/guzzle": "^7.5", 16 | "phpmailer/phpmailer": "^6.8", 17 | "blacksenator/fritzsoap": "^2.8" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "blacksenator\\": "src/", 22 | "Test\\": "tests/" 23 | }, 24 | "files": ["src/functions.php"] 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": ">=9" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config.example.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'url' => 'fritz.box', // your Fritz!Box IP 6 | 'user' => 'youruser', // your Fritz!Box user 7 | 'password' => 'xxxxxxxxxx', // your Fritz!Box user password 8 | ], 9 | 'phonebook' => [ 10 | 'whitelist' => [0], // phone books number is already known (first index = 0!) 11 | 'blacklist' => 1, // phone book in which the spam number should be recorded 12 | 'newlist' => 0, // optional: phone book in which the reverse searchable entries should be recorded 13 | 'refresh' => 1, // after how many days the phone books should be read again 14 | ], 15 | 'contact' => [ 16 | 'caller' => 'autom. gesperrt', // alias for the new unknown caller 17 | 'timestamp' => true, // adding timestamp to the caller: "[caller] ([timestamp])" 18 | 'type' => 'other', // type of phone line (home, work, mobil, fax etc.); 'other' = 'sonstige' 19 | ], 20 | 'filter' => [ 21 | 'msn' => [], // MSNs to react on (['321765', '321766']; empty = all) 22 | 'blockForeign' => true, // block unknown foreign numbers 23 | 'score' => 6, // 5 = neutral, increase the value to be less sensitive (max. 9) 24 | 'comments' => 3, // decrease the value to be less sensitive (min 3) 25 | ], 26 | 'logging' => [ 27 | 'log' => true, 28 | 'logPath' => '', // were callrouter_logging.txt schould be saved (default value is = './') 29 | ], 30 | /* 31 | 'email' => [ 32 | 'url' => 'smtp...', 33 | 'port' => 587, // alternativ 465 34 | 'secure' => 'tls', // alternativ 'ssl' 35 | 'user' => '[USER]', // your sender email adress e.g. account 36 | 'password' => '[PASSWORD]', 37 | 'sender' => '', // OPTIONAL:your email adress who is sending this email 38 | 'receiver' => 'blacksenator@github.com', // your email adress to receive the secured contacts 39 | 'debug' => 0, // 0 = off (for production use) 40 | // 1 = client messages 41 | // 2 = client and server messages 42 | ], 43 | */ 44 | 'test' => [ // if program is started with the -t option... 45 | 'numbers' => [ // ...the numbers are injected into the following calls 46 | '03681443300750', // tellows score > 5, comments > 3 47 | '0207565747377', // not existing NDC/STD (OKNz) 48 | '0618107162530', // valid NDC/STD, but invalid subscriber number 49 | '004433456778', // foreign number 50 | '', // unknown caler (uses CLIR) 51 | ], 52 | ], 53 | ]; 54 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | app: 5 | build: . 6 | volumes: 7 | - type: bind 8 | source: ./config.php 9 | target: /app/config.php 10 | logging: 11 | driver: "json-file" 12 | options: 13 | max-file: "5" 14 | max-size: "10m" 15 | restart: always 16 | -------------------------------------------------------------------------------- /fbcallrouter: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | addCommands(array( 13 | new RunCommand() 14 | )); 15 | 16 | $app->run(); 17 | -------------------------------------------------------------------------------- /fbcallrouter.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=FRITZ!Box call router 3 | After=network.service 4 | StartLimitIntervalSec=0 5 | 6 | [Service] 7 | Type=simple 8 | Restart=always 9 | RestartSec=1 10 | WorkingDirectory=/home/[youruser]/fbcallrouter/ 11 | ExecStart=/usr/bin/env php /home/[youruser]/fbcallrouter/fbcallrouter run 12 | User=[youruser] 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /src/ConfigTrait.php: -------------------------------------------------------------------------------- 1 | addOption('config', 'c', InputOption::VALUE_REQUIRED, 'config file', $default); 17 | } 18 | 19 | protected function loadConfig(InputInterface $input) 20 | { 21 | $configFile = $input->getOption('config'); 22 | 23 | if (!file_exists($configFile)) { 24 | throw new \Exception('Config file ' . $configFile . ' does not exist'); 25 | } 26 | 27 | $config = []; // make phpstan happy 28 | require_once($configFile); 29 | $this->config = $config; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/RunCommand.php: -------------------------------------------------------------------------------- 1 | setName('run') 18 | ->setDescription('perpetual') 19 | ->addOption('test', 't', InputOption::VALUE_NONE, 'test number(s)'); 20 | 21 | $this->addConfig(); 22 | } 23 | 24 | protected function execute(InputInterface $input, OutputInterface $output) 25 | { 26 | $this->loadConfig($input); 27 | error_log('Starting FRITZ!Box call router...'); 28 | $testNumbers = []; 29 | if ($input->getOption('test')) { 30 | $testNumbers = $this->config['test']['numbers'] ?? []; 31 | } 32 | callRouting($this->config, $testNumbers); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/callrouter/callmonitor.php: -------------------------------------------------------------------------------- 1 | socketAdress = 'tcp://' . $url['host'] . ':' . self::CALLMONITORPORT; 35 | $this->getSocket(); 36 | } 37 | 38 | /** 39 | * return the FRITZ!Box call monitor socket 40 | * 41 | * @param int $timeout 42 | * @return void 43 | */ 44 | private function getSocket(int $timeout = 1) 45 | { 46 | $stream = stream_socket_client($this->socketAdress, $errno, $errstr); 47 | if (!$stream) { 48 | $message = sprintf("Can't reach the call monitor port! Error: %s (%s)!", $errstr, $errno); 49 | throw new \Exception($message); 50 | } 51 | $socket = socket_import_stream($stream); 52 | socket_set_option($socket, SOL_SOCKET, SO_KEEPALIVE, 1); 53 | stream_set_timeout ($stream, $timeout); 54 | $this->nextSocketRefresh = time() + self::REFRESHTIME; 55 | $this->fritzBoxSocket = $stream; 56 | } 57 | 58 | /** 59 | * check and update socket status 60 | * 61 | * @return string 62 | */ 63 | public function refreshSocket() 64 | { 65 | $msg = null; 66 | if (time() > $this->nextSocketRefresh) { 67 | $this->getSocket(); // refresh socket 68 | $msg = 'Status: Regular Socket refresh'; 69 | } elseif (stream_get_meta_data($this->fritzBoxSocket)['eof']) { // socket died 70 | $this->getSocket(); // refresh socket 71 | $msg = 'Status: Dead Socket refreshed'; 72 | } 73 | 74 | return $msg; 75 | } 76 | 77 | /** 78 | * returns the socket output 79 | * 80 | * @param array 81 | */ 82 | public function getSocketStream() 83 | { 84 | if (($output = fgets($this->fritzBoxSocket)) != false) { 85 | return $this->parseCallString($output); 86 | } 87 | 88 | return [ 89 | 'extern' => null, 90 | 'type' => null, 91 | ]; 92 | } 93 | 94 | 95 | /** 96 | * parse a string from call monitor socket output. Four different strings 97 | * are known: 98 | * timestamp;CALL;connectionID;extension;MSN;extern; 99 | * timestamp;RING;connectionID;extern;MSN; 100 | * timestamp;CONNECT;connectionID;extension;extern/MSN; 101 | * timestamp;DISCONNECT;connectionID;duration; 102 | * e.g. 103 | * "01.01.20 10:10:10;RING;0;01701234567;987654;SIP0;\r\n" 104 | * "18.11.12 00:13:26;DISCONNECT;0;7;\r\n" 105 | * 106 | * @param string $line 107 | * @return array $result 108 | */ 109 | private function parseCallString(string $line): array 110 | { 111 | $params = explode(';', str_replace(';\\r\\n', '', $line)); 112 | 113 | $result = [ 114 | 'timestamp' => $params[0], 115 | 'type' => $params[1], 116 | 'conID' => $params[2], 117 | ]; 118 | if ($params[1] == 'RING') { 119 | $result += [ 120 | 'extern' => $params[3], 121 | 'intern' => $params[4], 122 | 'device' => $params[5], 123 | ]; 124 | } elseif ($params[1] == 'CALL') { 125 | $result += [ 126 | 'extension' => $params[3], 127 | 'intern' => $params[4], 128 | 'extern' => $params[5], 129 | 'device' => $params[6], 130 | ]; 131 | } elseif ($params[1] == 'CONNECT') { 132 | $result += [ 133 | 'extension' => $params[3], 134 | 'number' => $params[4], 135 | ]; 136 | } else { // DISCONNECT 137 | $result += [ 138 | 'duration' => $params[3], 139 | ]; 140 | } 141 | 142 | return $result; 143 | } 144 | 145 | /** 146 | * returns socket adress 147 | * 148 | * @return string $this->socketAdress 149 | */ 150 | public function getSocketAdress() 151 | { 152 | return $this->socketAdress; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/callrouter/callrouter.php: -------------------------------------------------------------------------------- 1 | '', // will be filled automatically 34 | 'type' => 'RING', // or 'CALL' 35 | 'conID' => '0', // not in use 36 | 'extern' => '00000000', // testnumber 37 | 'intern' => '0000000', // MSN 38 | 'device' => 'SIP0' // not in use 39 | ]; 40 | 41 | private 42 | $contactConfig = [], 43 | $realName = '', 44 | $blockForeign, 45 | $mSNs = [], 46 | $proofList = [], // list of all telephone books to be checked against 47 | $blackList, // index of spam phone book 48 | $newList, // index of phone book for valid numbers (optional) 49 | $proofListNumbers = [], // all known numbers 50 | $centralNumbers = [], 51 | $testNumbers = [], // test case numbers 52 | $testCases = 0, // number of test cases 53 | $testCounter = 0, 54 | $callMonitor, // class instance 55 | $callMonitorValues = [], // keep call monitor values 56 | $phoneTools, // class instance 57 | $dialerCheck, // class instance 58 | $logging, // class instance 59 | $infoMail = null, // class instance 60 | $ownArea = [], // contains your area code 61 | $contactEntry = [], // contains the data for the telephone book entry 62 | $mailNotify = false, 63 | $mailText = [], // collector of logging info for email 64 | $elapse = 0, 65 | $nextUpdate = 0; // timestamp for refreshing phone books 66 | 67 | public function __construct(array $config, array $testNumbers = []) 68 | { 69 | $this->logging = new logging($config['logging']); 70 | $this->setLogging(99, ['Start the fbcallrouter initialization process']); 71 | $this->testNumbers = $testNumbers; 72 | $this->testCases = count($this->testNumbers); 73 | $this->contactConfig = $config['contact']; 74 | $this->realName = $this->contactConfig['caller']; 75 | $this->blockForeign = $config['filter']['blockForeign'] ?? false; 76 | $this->mSNs = $config['filter']['msn'] ?? []; 77 | $this->phoneTools = new phonetools($config['fritzbox']); 78 | $this->ownArea = $this->phoneTools->getOwnAreaCode(); 79 | $this->setPhoneBooks($config['phonebook']); 80 | $this->refreshPhoneBooks(); // initial load 81 | $this->callMonitor = new callmonitor($this->phoneTools->getURL()); 82 | $this->dialerCheck = new dialercheck($config['filter']); 83 | if (isset($config['email'])) { 84 | $this->infoMail = new infomail($config['email']); 85 | } 86 | echo 'On guard...' . PHP_EOL; 87 | $this->setLogging(0, [$this->callMonitor->getSocketAdress()]); 88 | } 89 | 90 | /** 91 | * sorting and arranging list of phone books 92 | * 93 | * @return void 94 | */ 95 | private function sortProofList() 96 | { 97 | $this->proofList = array_unique($this->proofList); 98 | asort($this->proofList); 99 | array_values($this->proofList); 100 | } 101 | 102 | /** 103 | * set phone books 104 | * 105 | * @param array $config 106 | * @return void 107 | */ 108 | private function setPhoneBooks(array $config) 109 | { 110 | $this->proofList = $config['whitelist'] ?? [0]; 111 | $this->blackList = $config['blacklist'] ?? 1; 112 | $this->proofList[] = $this->blackList; 113 | $this->newList = $config['newlist'] ?? -1; 114 | if ($this->newList >= 0) { 115 | $this->proofList[] = $this->newList; 116 | } 117 | $this->sortProofList(); 118 | $this->phoneTools->checkListOfPhoneBooks($this->proofList); 119 | $refresh = $config['refresh'] ?? 1; 120 | $this->elapse = ($refresh < 1) ? self::ONEDAY : $refresh * self::ONEDAY; 121 | } 122 | 123 | /** 124 | * set logging text 125 | * 126 | * @param int $stringID 127 | * @param array $infos 128 | * @return string 129 | */ 130 | public function setLogging(int $stringID, array $infos) 131 | { 132 | return $this->logging->setLogging($stringID, $infos); 133 | } 134 | 135 | /** 136 | * returns rearranged timestamp data 137 | * 138 | * @param string $timestamp // dd.mm.yy hh:mm:ss 139 | * @return string // yyyy.mm.dd hh:mm:ss 140 | */ 141 | private function getTimeStampReverse(string $timeStamp) 142 | { 143 | $parts = explode(' ', $timeStamp); 144 | $date = explode('.', $parts[0]); 145 | 146 | return '20' . $date[2] . '.' . $date[1] . '.' . $date[0] . ' ' . $parts[1]; 147 | } 148 | 149 | /** 150 | * get real name 151 | * 152 | * @param string $timeStamp 153 | * @return string 154 | */ 155 | private function getRealName(string $timeStamp) 156 | { 157 | if ($this->contactConfig['timestamp']) { 158 | return $this->realName . ' (' . $this->getTimeStampReverse($timeStamp) . ')'; 159 | } else { 160 | return $this->realName; 161 | } 162 | } 163 | 164 | /** 165 | * returns phone numbers from phone books 166 | * 167 | * @param array $phoneBooks 168 | * @return array $phoneBookNumbers 169 | */ 170 | private function getPhoneBookNumbers(array $phoneBooks = [0]) 171 | { 172 | $phoneNumbers = []; 173 | foreach ($phoneBooks as $phoneBook) { 174 | if (empty($numbers = $this->phoneTools->getPhoneNumbers($phoneBook))) { 175 | $this->setLogging(8, [$phoneBook]); 176 | } else { 177 | $phoneNumbers = array_merge($phoneNumbers, $numbers); 178 | date_default_timezone_set('Europe/Berlin'); 179 | $this->setLogging(1, [$phoneBook, date('d.m.Y H:i:s', $this->nextUpdate)]); 180 | } 181 | } 182 | 183 | return $phoneNumbers; 184 | } 185 | 186 | /** 187 | * returns and extracts the central numbers 188 | * 189 | * @return array $centralNumbers 190 | */ 191 | private function getCentralNumbers() 192 | { 193 | $centralNumbers = []; 194 | foreach ($this->proofListNumbers as $key => $number) { 195 | if (substr($number, -1) == '*') { 196 | $centralNumbers[] = substr($number, 0, -1); 197 | unset($this->proofListNumbers[$key]); 198 | } 199 | } 200 | $this->proofListNumbers = array_values($this->proofListNumbers); 201 | 202 | return $centralNumbers; 203 | } 204 | 205 | /** 206 | * returns reread phone book numbers 207 | * 208 | * @return void 209 | */ 210 | public function refreshPhoneBooks() 211 | { 212 | if (time() > $this->nextUpdate) { 213 | $this->nextUpdate = time() + $this->elapse; 214 | $this->phoneTools->refreshContactClient(); 215 | $this->proofListNumbers = $this->getPhoneBookNumbers($this->proofList); 216 | $this->centralNumbers = $this->getCentralNumbers(); 217 | // for debugging perposes uncomment the following line 218 | // file_put_contents('numbers.txt', print_r($this->proofListNumbers, true)); 219 | } 220 | } 221 | 222 | /** 223 | * getting the array from the call monitor socket stream 224 | * 225 | * @return array $this->callMonitorValues 226 | */ 227 | public function getCallMonitorStream() 228 | { 229 | $this->mailText = []; 230 | $this->setCallMonitorValues($this->callMonitor->getSocketStream()); 231 | 232 | return $this->callMonitorValues; 233 | } 234 | 235 | /** 236 | * set call monitor values 237 | * 238 | * @return void 239 | */ 240 | public function setCallMonitorValues(array $values = []) 241 | { 242 | $this->callMonitorValues = $values; 243 | } 244 | 245 | /** 246 | * set entry in phone book 247 | * 248 | * @param array $entry 249 | * @param int $logIndex 250 | * @return string 251 | */ 252 | private function setPhoneBookEntry(int $logIndex, array $additional = []) 253 | { 254 | $this->phoneTools->setPhoneBookEntry($this->contactEntry); 255 | $this->proofListNumbers[] = $this->contactEntry['number']; 256 | $this->mailNotify = true; 257 | $logInfo = [$this->contactEntry['name'], $this->contactEntry['phonebook']]; 258 | if (!empty($additional)) { 259 | $logInfo = array_merge($logInfo, $additional); 260 | } 261 | 262 | return $this->setLogging($logIndex, $logInfo); 263 | } 264 | 265 | /** 266 | * set contact data for phone book entry 267 | * 268 | * @param int $phonebook 269 | * @param string $number 270 | * @param string $name (optional) 271 | * @return array 272 | */ 273 | private function setContactEntry(int $phonebook, string $number, string $name = null) 274 | { 275 | $realName = $this->getRealName($this->callMonitorValues['timestamp']); 276 | 277 | $this->contactEntry = [ 278 | 'phonebook' => $phonebook, 279 | 'name' => $name ?? $realName, 280 | 'number' => $number, 281 | 'type' => $this->contactConfig['type'], 282 | ]; 283 | } 284 | 285 | /** 286 | * returns true if MSN from call monitor stream is in the list of numbers to 287 | * react on 288 | */ 289 | public function isMSNtoProof (string $msn) 290 | { 291 | if (count($this->mSNs) && !in_array($msn, $this->mSNs)) { 292 | return false; 293 | } 294 | 295 | return true; 296 | } 297 | 298 | /** 299 | * checking presumably foreign numbers 300 | * 301 | * @param string $number 302 | * @param int $numberLength 303 | * @return bool 304 | */ 305 | private function parseForeignNumber(string $number, int $numberLength) 306 | { 307 | $result = true; 308 | $countryData = []; 309 | if ($this->blockForeign) { 310 | $this->mailText[] = $this->setPhoneBookEntry(4); 311 | } elseif ($numberLength < 7 || $numberLength > 17) { 312 | // see class comment at top of file 313 | $this->mailText[] = $this->setPhoneBookEntry(13); 314 | } elseif (($countryData = $this->phoneTools->getCountry($number)) == false) { 315 | // unknown country code 316 | $this->mailText[] = $this->setPhoneBookEntry(3); 317 | } elseif (substr($countryData['national'], 0) == '0'){ 318 | // illegal start of area code 319 | $this->mailText[] = $this->setPhoneBookEntry(5); 320 | } else { 321 | $this->callMonitorValues += $countryData; 322 | $result = false; 323 | } 324 | 325 | return $result; 326 | } 327 | 328 | /** 329 | * decomposition of atypically composed cellular number consists of: 330 | * [AREACODE][COUNTRYCODE(49)][CELLULARPREFIX][NUMBER] 331 | * 332 | * @param string $number 333 | * @return bool 334 | */ 335 | private function getVeiledCellular(string $number) 336 | { 337 | $nationalData = []; 338 | $rear = '0' . substr($number, $this->ownArea['length'] + 2); 339 | if ( 340 | ($nationalData = $this->phoneTools->getArea($rear)) != false 341 | && $this->phoneTools->isCellularCode($nationalData['prefix']) 342 | ){ 343 | // adding transmitted number 344 | $this->mailText[] = $this->setPhoneBookEntry(14); 345 | // adding derived (actual) number 346 | $this->contactEntry['number'] = $rear; 347 | $this->mailText[] = $this->setPhoneBookEntry(15, [$rear]); 348 | return true; 349 | } 350 | 351 | return false; 352 | } 353 | 354 | /** 355 | * decomposition of two transmitted numbers (second one bracketed): 356 | * [USER_PROVIDED_NUMBER]([NETWORK_PROVIDED_NUMBER]) 357 | * @see https://avm.de/service/wissensdatenbank/dok/FRITZ-Box-7490/1613_In-Anrufliste-werden-zwei-Rufnummern-fur-einen-Anruf-angezeigt/ 358 | * 359 | * @param string $number 360 | * @return void 361 | */ 362 | private function separateBracketedNumber(string $number) 363 | { 364 | $result = false; 365 | $networkProvided = preg_replace('/[^0-9]/', '', substr(strstr($number, '('), 1, -1)); 366 | $userProvided = trim(strstr($number, '(', true)); 367 | // adding transmitted number 368 | if ($this->isNumberKnown($userProvided) == false) { 369 | $this->contactEntry['number'] = $userProvided; 370 | $this->mailText[] = $this->setPhoneBookEntry(16, [$userProvided]); 371 | $result = true; 372 | } 373 | // adding bracketed number 374 | if ($this->isNumberKnown($networkProvided) == false) { 375 | $this->contactEntry['number'] = $networkProvided; 376 | $this->mailText[] = $this->setPhoneBookEntry(17, [$networkProvided]); 377 | $result = true; 378 | } 379 | 380 | return $result; 381 | } 382 | 383 | /** 384 | * checking domestic numbers 385 | * 386 | * @param string $number 387 | * @param int $numberLength 388 | * @return bool 389 | */ 390 | private function parseDomesticNumber(string $number, int $numberLength) 391 | { 392 | $nationalData = []; 393 | $result = true; 394 | if (substr($number, 0, 1) != '0') { 395 | // presumably obsolete, since the area code is always transmitted even with local calls? 396 | $this->setLogging(99, ['No trunk prefix (VAZ). Probably domestic call. No action possible']); 397 | } 398 | if (($nationalData = $this->phoneTools->getArea($number)) == false) { 399 | // unknown german area code 400 | $this->mailText[] = $this->setPhoneBookEntry(5); 401 | } elseif ( 402 | // seldom: landline fake numbers starting with "0"; exclusiv cellular numbers 403 | $this->phoneTools->isCellularCode($nationalData['prefix']) == false 404 | && substr($nationalData['subscriber'], 0, 1) == '0' 405 | ) { 406 | $this->mailText[] = $this->setPhoneBookEntry(9); 407 | } elseif (preg_match('/^' . $this->ownArea['code'] . '49[1][5-7][0-9]+/', $number)) { 408 | // particularly observed case: [AREACODE]49[CELLUAR_NUMBER] 409 | $result = $this->getVeiledCellular($number); 410 | } elseif (strpos($number, '(') !== false) { 411 | // particularly observed case: [NUMBER]([ALTNUMBER]) 412 | $result = $this->separateBracketedNumber($number); 413 | } elseif ($numberLength < 8 || $numberLength > 14) { 414 | // see class comment at top of file 415 | $this->mailText[] = $this->setPhoneBookEntry(13); 416 | } else { 417 | $result = false; 418 | } 419 | 420 | return $result; 421 | } 422 | 423 | /** 424 | * sarch for number in web directories 425 | * 426 | * @param string $number 427 | * @param bool $isForeign 428 | * @return void 429 | */ 430 | private function webSearch(string $number, bool $isForeign) 431 | { 432 | $webResult = []; 433 | $checkDasOertliche = false; 434 | if ($webResult = $this->dialerCheck->getRating($number)) { 435 | $score = $webResult['score']; 436 | $comments = $webResult['comments']; 437 | if ($this->dialerCheck->proofRating($webResult)) { 438 | $this->mailText[] = $this->setPhoneBookEntry(6, [$score, $comments] 439 | ); 440 | } else { 441 | $this->mailText[] = $this->setLogging(7, [$score, $comments]); 442 | $this->mailNotify = true; 443 | $isForeign ?: $checkDasOertliche = true; 444 | } 445 | } else { 446 | $this->mailText[] = $this->setLogging(99, ['Request in spam databases failed!']); 447 | $this->mailNotify = true; 448 | $isForeign ?: $checkDasOertliche = true; 449 | } 450 | if ($checkDasOertliche) { 451 | $webResult = $this->checkDasOertliche($number); 452 | } 453 | if (isset($webResult['url'])) { 454 | $this->setLogging(11, [$webResult['url']]); 455 | $this->mailText[] = $webResult['deeplink']; 456 | $this->mailNotify = true; 457 | } 458 | } 459 | 460 | /** 461 | * perform the validation of incomming calls ('RING') 462 | * 463 | * @return void 464 | */ 465 | public function runInboundValidation() 466 | { 467 | if ($this->isMSNtoProof($this->callMonitorValues['intern'])) { 468 | $this->mailNotify = false; 469 | $isSortedOut = true; 470 | $number = $this->callMonitorValues['extern']; 471 | $numberLength = strlen($number); 472 | if ($numberLength == 0) { 473 | $this->setLogging(99, ['Caller uses CLIR - no action possible']); 474 | } elseif ($this->isNumberKnown($number) === false) { 475 | $this->setContactEntry($this->blackList, $number); 476 | $this->mailText[] = $this->setLogging(2, [$number, $this->callMonitorValues['intern']]); 477 | $isForeign = (substr($number, 0, 2) === '00') ? true : false; 478 | if ($isForeign) { // foreign number specific 479 | $isSortedOut = $this->parseForeignNumber($number, $numberLength); 480 | } else { // domestic numbers specific 481 | $isSortedOut = $this->parseDomesticNumber($number, $numberLength); 482 | } 483 | } 484 | if (!$isSortedOut) { 485 | $this->webSearch($number, $isForeign); 486 | } 487 | } 488 | } 489 | 490 | /** 491 | * perform the validation of out going calls ('CALL') 492 | * 493 | * @return void 494 | */ 495 | public function runOutboundValidation() 496 | { 497 | if ($this->isMSNtoProof($this->callMonitorValues['intern'])) { 498 | $number = str_replace('#', '', $this->callMonitorValues['extern']); 499 | $message = $this->setLogging(12, [$number]); 500 | if ($this->isNumberKnown($number) === false) { 501 | $this->mailNotify = false; 502 | $this->mailText[] = $message; 503 | $webResult = $this->checkDasOertliche($number); 504 | if (isset($webResult['url'])) { 505 | $this->setLogging(11, [$webResult['url']]); 506 | $this->mailText[] = $webResult['deeplink']; 507 | $this->mailNotify = true; 508 | } 509 | } 510 | } 511 | } 512 | 513 | /** 514 | * checks if number is known in Das Örtliche public phone book 515 | * 516 | * @param string $number 517 | * @return array|bool 518 | */ 519 | public function checkDasOertliche($number) 520 | { 521 | if ( 522 | $this->newList >= 0 523 | && ($webResult = $this->dialerCheck->getDasOertliche($number)) 524 | ) { 525 | $this->setContactEntry($this->newList, $number, $webResult['name']); 526 | $this->mailText[] = $this->setPhoneBookEntry(10); 527 | } 528 | 529 | return $webResult; 530 | } 531 | 532 | /** 533 | * returns true if a number starts with a known central number 534 | * 535 | * @param string $number 536 | * @return bool 537 | */ 538 | private function isDirectInwardDial(string $number) 539 | { 540 | foreach ($this->centralNumbers as $centralNumber) { 541 | if (strpos($number, $centralNumber) === 0) { 542 | return true; 543 | } 544 | } 545 | 546 | return false; 547 | } 548 | 549 | /** 550 | * returns if number is known in one of the phone books or begins with an 551 | * already known central number 552 | * 553 | * @param string $number 554 | * @return bool 555 | */ 556 | private function isNumberKnown(string $number) 557 | { 558 | if (in_array($number, $this->proofListNumbers)) { 559 | return true; 560 | } 561 | 562 | return $this->isDirectInwardDial($number); 563 | } 564 | 565 | /** 566 | * get current staus of socket and refresh 567 | * 568 | * @return void 569 | */ 570 | public function refreshSocket() 571 | { 572 | $socketStatus = $this->callMonitor->refreshSocket(); 573 | $socketStatus == null ?: $this->setLogging(99, [$socketStatus]); 574 | } 575 | 576 | /** 577 | * send email 578 | * 579 | * @return void 580 | */ 581 | public function sendMail() 582 | { 583 | if (isset($this->infoMail) && $this->mailNotify) { 584 | $msg = $this->infoMail->sendMail($this->callMonitorValues, $this->mailText); 585 | if ($msg <> null) { 586 | $this->setLogging(99, [$msg]); 587 | } 588 | } 589 | $this->mailNotify = false; 590 | $this->contactEntry = []; 591 | } 592 | 593 | /** 594 | * setting call monitor values for debugging purposes 595 | * 596 | * @return array 597 | */ 598 | public function setDebugStream() 599 | { 600 | $this->callMonitorValues = self::DBGSTRM; 601 | $this->callMonitorValues['timestamp'] = date('d.m.y H:i:s'); 602 | 603 | return $this->callMonitorValues; 604 | } 605 | 606 | /** 607 | * set one of n test numbers in sequence, as specified in the configuration 608 | * when executed with the "-t" parameter 609 | * 610 | * @return void 611 | */ 612 | public function getTestInjection() 613 | { 614 | if ($this->testCases > 0) { 615 | if ($this->testCounter === 0) { 616 | $this->setLogging(99, ['START OF TEST OPERATION']); 617 | } elseif ($this->testCounter === $this->testCases) { 618 | $this->setLogging(99, ['END OF TEST OPERATION']); 619 | $this->testCases = 0; 620 | } 621 | } 622 | if ($this->testCounter < $this->testCases) { // as long as the test case was not used 623 | echo sprintf('Running test case %s of %s', $this->testCounter + 1, $this->testCases) . PHP_EOL; 624 | // inject the next sanitized number from row 625 | $this->callMonitorValues['extern'] = $this->phoneTools->sanitizeNumber($this->testNumbers[$this->testCounter]); 626 | $this->testCounter++; 627 | } 628 | } 629 | } 630 | -------------------------------------------------------------------------------- /src/callrouter/dialercheck.php: -------------------------------------------------------------------------------- 1 | '9', 25 | 'Verwirrend' => '7', 26 | 'Unbekannte' => '5', 27 | 'Egal' => '3', 28 | 'Positiv' => '1', 29 | ], 30 | ]; 31 | const CLVRDLR = [ 32 | 'https://www.cleverdialer.de/telefonnummer/', 33 | 'Clever Dailer', 34 | ]; 35 | const TELSPIO = [ 36 | 'https://www.telefonspion.de/', 37 | 'Telefonspion', 38 | ]; 39 | //const WRRFTAN = 'https://wer-ruftan.de/Nummer/'; // offen 40 | //const WMGEHRT = 'https://www.wemgehoert.de/nummer/' // offen 41 | const WRHTANG = [ 42 | 'https://www.werhatangerufen.com/', 43 | 'WerHatAngerufen', 44 | ]; 45 | const TELLOWS = [ 46 | 'http://www.tellows.de/basic/num/%s?xml=1&partner=test&apikey=test123', 47 | 'https://www.tellows.de/num/', 48 | 'tellows', 49 | ]; 50 | const DSOERTL = [ 51 | 'https://www.dasoertliche.de/rueckwaertssuche/?ph=', 52 | 'https://www.dasoertliche.de/?form_name=search_inv&ph=', 53 | 'Das Örtliche' 54 | ]; 55 | 56 | private $score; // rating normalized to tellows (1 - 9) 57 | private $comments; // in most cases number of valuations 58 | 59 | public function __construct(array $filter) 60 | { 61 | $this->score = ($filter['score'] > 9) ? 9 : $filter['score']; 62 | $this->comments = ($filter['comments'] < 3) ? 3 : $filter['comments']; 63 | } 64 | 65 | /** 66 | * assamble the deeplink string 67 | * 68 | * @param string $url 69 | * @param string $label 70 | * @return string 71 | */ 72 | private function getDeepLinkString(string $url, string $label) 73 | { 74 | return 'Number traced in: ' . $label . ''; 75 | } 76 | 77 | /** 78 | * return the tellows rating and number of comments 79 | * 80 | * @param string $number phone number 81 | * @return array|bool $score array of rating and number of comments or false 82 | */ 83 | private function getTellowsRating(string $number) 84 | { 85 | $url = sprintf(self::TELLOWS[0], $number); 86 | if (($rating = @simplexml_load_file($url)) == false) { 87 | return false; 88 | } 89 | $rating->asXML(); 90 | $url = self::TELLOWS[1] . $number; 91 | 92 | return [ 93 | 'score' => intval($rating->score), 94 | 'comments' => intval($rating->comments), 95 | 'url' => $url, 96 | 'deeplink' => $this->getDeepLinkString($url, self::TELLOWS[2]), 97 | ]; 98 | } 99 | 100 | /** 101 | * converting an HTML response into a SimpleXMLElement 102 | * 103 | * @param string $response 104 | * @return SimpleXMLElement $xmlSite 105 | */ 106 | private function convertHTMLtoXML($response) 107 | { 108 | $dom = new DOMDocument(); 109 | $dom->preserveWhiteSpace = false; 110 | @$dom->loadHTML($response); 111 | 112 | return simplexml_import_dom($dom); 113 | } 114 | 115 | /** 116 | * returns a websites HTML as XML 117 | * 118 | * @param string $url 119 | * @return SimpleXMLelement|bool 120 | */ 121 | private function getWebsiteAsXML(string $url) 122 | { 123 | $html = @file_get_contents($url); 124 | 125 | return !$html ? false : $this->convertHTMLtoXML($html); 126 | } 127 | 128 | /** 129 | * returns the equivalent from one of/to five stars to the score from one to 130 | * nine, where five stars are a score of one and one star is the score of nine 131 | * 132 | * @param string|float $stars 133 | * @return float as 1 .. 9 134 | */ 135 | private function convertStarsToScore($stars) 136 | { 137 | return round(intval($stars) * 2) / 2 * -2 + 11; 138 | } 139 | 140 | /** 141 | * return the werruft.info rating and number of comments with screen 142 | * scraping of 143 | *
144 | * 0 Negativ 145 | * 0 Verwirrend 146 | * 0 Unbekannte 147 | * 0 Egal 148 | * 0 Positiv 149 | *
150 | * 151 | * @param string $number phone number 152 | * @return array|bool $score array of rating and number of comments or false 153 | */ 154 | private function getWerRuftInfoRating(string $number) 155 | { 156 | $url = self::WERRUFT[0] . $number . '/'; 157 | if (($rawXML = $this->getWebsiteAsXML($url)) == false) { 158 | return false; 159 | } 160 | if (count($comments = $rawXML->xpath('//i[contains(@class, "comop")]')) != 5) { 161 | return false; 162 | } 163 | $totalComment = 0; 164 | foreach($comments as $comment) { 165 | $totalComment += (int)$comment; 166 | } 167 | $title = $rawXML->xpath('//title'); 168 | if (preg_match('/\((.*?)\:/', $title[0], $match)) { 169 | $score = strtr($match[1], self::WERRUFT[2]); 170 | } else { 171 | return false; 172 | } 173 | 174 | return [ 175 | 'score' => intval($score), 176 | 'comments' => $totalComment, 177 | 'url' => $url, 178 | 'deeplink' => $this->getDeepLinkString($url, self::WERRUFT[1]), 179 | ]; 180 | } 181 | 182 | /** 183 | * return the cleverdialer rating and number of comments 184 | * 185 | * @param string $number phone number 186 | * @return array|bool $score array of rating and number of comments or false 187 | */ 188 | private function getCleverDialerRating(string $number) 189 | { 190 | $url = self::CLVRDLR[0] . $number; 191 | if (($rawXML = $this->getWebsiteAsXML($url)) == false) { 192 | return false; 193 | } 194 | if (count($valuation = $rawXML->xpath('//div[@class = "rating-text"]'))) { 195 | $stars = intval(str_replace(' von 5 Sternen', '', $valuation[0]->span[0])); 196 | if ($stars > 0) { 197 | $comments = preg_replace('/[^0-9]/', '', $valuation[0]->span[1]); 198 | return [ 199 | 'score' => $this->convertStarsToScore($stars), 200 | 'comments' => intval($comments), 201 | 'url' => $url, 202 | 'deeplink' => $this->getDeepLinkString($url, self::CLVRDLR[1]), 203 | ]; 204 | } 205 | } 206 | 207 | return false; 208 | } 209 | 210 | /** 211 | * return the telefonspion rating and number of comments 212 | * 213 | * @param string $number phone number 214 | * @return array|bool $score array of rating and number of comments or false 215 | */ 216 | private function getTelefonSpionRating(string $number) 217 | { 218 | $url = self::TELSPIO[0] . $number; 219 | if (($rawXML = $this->getWebsiteAsXML($url)) == false) { 220 | return false; 221 | } 222 | $title = (string)$rawXML->xpath('//title')[0]; 223 | $titleParts = explode(' - ', $title); 224 | if (count($titleParts) == 3) { 225 | $values = []; 226 | preg_match_all('/\d+/', $titleParts[2], $values); 227 | if (count($values[0]) > 3 && intval($values[0][0]) > 0) { 228 | return [ 229 | 'score' => -0.8 * intval($values[0][2]) + 9, 230 | 'comments' => intval($values[0][1]), 231 | 'url' => $url, 232 | 'deeplink' => $this->getDeepLinkString($url, self::TELSPIO[1]), 233 | ]; 234 | } 235 | } 236 | 237 | return false; 238 | } 239 | 240 | /** 241 | * return the werhatangerufen rating and number of comments 242 | * 243 | * @param string $number phone number 244 | * @return array|bool $score array of rating and number of comments or false 245 | */ 246 | private function getWerHatAngerufenRating(string $number) 247 | { 248 | $url = self::WRHTANG[0] . $number; 249 | if (($rawXML = $this->getWebsiteAsXML($url)) == false) { 250 | return false; 251 | } 252 | $valuation = $rawXML->xpath('//span[@class="rating"]'); 253 | if (count($valuation)) { 254 | $stars = intval(substr(str_replace('Bewertung: ', '', $valuation[0]), 0, 1)); 255 | if ($stars > 0) { 256 | $titleParts = explode(' // ', $rawXML->xpath('//title')[0]); 257 | if (count($titleParts) == 2) { 258 | return [ 259 | 'score' => $this->convertStarsToScore($stars), 260 | 'comments' => intval(str_replace(' Bewertungen', '', $titleParts[1])), 261 | 'url' => $url, 262 | 'deeplink' => $this->getDeepLinkString($url, self::WRHTANG[1]), 263 | ]; 264 | } 265 | } 266 | } 267 | 268 | return false; 269 | } 270 | 271 | /** 272 | * returns if rating is above or equal to the user limits 273 | * 274 | * @param array $rating 275 | * @return bool 276 | */ 277 | public function proofRating(array $rating) 278 | { 279 | if ($rating['score'] >= $this->score && 280 | $rating['comments'] >= $this->comments) { 281 | return true; 282 | } 283 | 284 | return false; 285 | } 286 | 287 | /** 288 | * proofs cascading if the number is known in online list as bad rated 289 | * 290 | * @param string $number 291 | * @param string $score 292 | * @param string $comments 293 | * @return array|bool 294 | */ 295 | public function getRating(string $number) 296 | { 297 | $proofedRating['score'] = null; 298 | if ($rating = $this->getWerRuftInfoRating($number)) { 299 | if ($this->proofRating($rating)) { 300 | return $rating; 301 | } else { 302 | $proofedRating = $rating; 303 | } 304 | } 305 | if ($rating = $this->getCleverDialerRating($number)) { 306 | if ($this->proofRating($rating)) { 307 | return $rating; 308 | } else { 309 | ($rating['score'] <= $proofedRating['score']) ?: $proofedRating = $rating; 310 | } 311 | } 312 | if ($rating = $this->getTelefonSpionRating($number)) { 313 | if ($this->proofRating($rating)) { 314 | return $rating; 315 | } else { 316 | ($rating['score'] <= $proofedRating['score']) ?: $proofedRating = $rating; 317 | } 318 | } 319 | if ($rating = $this->getWerHatAngerufenRating($number)) { 320 | if ($this->proofRating($rating)) { 321 | return $rating; 322 | } else { 323 | ($rating['score'] <= $proofedRating['score']) ?: $proofedRating = $rating; 324 | } 325 | } 326 | if ($rating = $this->getTellowsRating($number)) { 327 | if ($this->proofRating($rating)) { 328 | return $rating; 329 | } else { 330 | ($rating['score'] <= $proofedRating['score']) ?: $proofedRating = $rating; 331 | } 332 | } 333 | 334 | $result = (isset($proofedRating['score'])) ? $proofedRating : false; 335 | 336 | return $result; 337 | } 338 | 339 | /** 340 | * return the name from Das Örtliche 341 | * 342 | * @param string $number phone number 343 | * @return array|bool $score array of rating and number of comments or false 344 | */ 345 | public function getDasOertliche(string $number) 346 | { 347 | $url = self::DSOERTL[0] . $number; 348 | if (($rawXML = $this->getWebsiteAsXML($url)) == false) { 349 | $url = self::DSOERTL[1] . $number; // second attemp 350 | if (($rawXML = $this->getWebsiteAsXML($url)) == false) { 351 | return false; 352 | } 353 | } 354 | if ($rawXML->xpath('//div[@class="nonumber01"]')) { // unknown 355 | return false; 356 | } 357 | if ($result = $rawXML->xpath('//div[@class="nonumber"]')) { // alternative 358 | if (($altNumber = filter_var($result[0]->p, FILTER_SANITIZE_NUMBER_INT)) != '') { 359 | $url = self::DSOERTL[1] . $altNumber; 360 | if (($rawXML = $this->getWebsiteAsXML($url)) == false) { 361 | return false; 362 | } 363 | } 364 | } 365 | if ($result = $rawXML->xpath('//a[@class="hitlnk_name"]')) { 366 | return [ 367 | 'name' => trim($result[0]), 368 | 'url' => $url, 369 | 'deeplink' => $this->getDeepLinkString($url, self::DSOERTL[2]), 370 | ]; 371 | } 372 | 373 | return false; 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /src/callrouter/infomail.php: -------------------------------------------------------------------------------- 1 | mail = new PHPMailer(true); 23 | $this->mail->CharSet = "text/html; charset=UTF-8;"; 24 | $this->mail->isSMTP(); // tell PHPMailer to use SMTP 25 | $this->mail->SMTPDebug = $account['debug']; 26 | $this->mail->Host = $account['url']; // set the hostname of the mail server 27 | $this->mail->Port = $account['port']; // set the SMTP port number - likely to be 25, 465 or 587 28 | $this->mail->SMTPSecure = $account['secure']; 29 | $this->mail->SMTPAuth = true; // whether to use SMTP authentication 30 | $this->mail->Username = $account['user']; // username to use for SMTP authentication 31 | $this->mail->Password = $account['password']; // password to use for SMTP authentication 32 | $sender = $account['sender'] ?? $account['user']; // if optional sender adress is choosen 33 | $this->mail->setFrom($sender, 'fbcallrouter'); // set who the message is to be sent fromly-to address 34 | $this->mail->addAddress($account['receiver']); // set who the message is to be sent to 35 | } 36 | 37 | /** 38 | * send email 39 | * 40 | * @param array $callMonitorValues 41 | * @param array $protocoll 42 | * @return string|void 43 | */ 44 | public function sendMail(array $callMonitorValues, array $protocol) 45 | { 46 | $number = $callMonitorValues['extern']; 47 | $way = ($callMonitorValues['type'] == 'RING') ? 'from' : 'to'; 48 | $body = nl2br('The following results and actions have been recorded:' . PHP_EOL . PHP_EOL); 49 | foreach ($protocol as $lines) { 50 | $body .= nl2br($lines . PHP_EOL); 51 | } 52 | $this->mail->Subject = sprintf('fbcallrouter traced a call %s number %s', $way, $number); 53 | $this->mail->Body = $body; 54 | $this->mail->isHTML(true); 55 | if (!$this->mail->send()) { // send the message, check for errors 56 | return 'Mailer Error: ' . $this->mail->ErrorInfo; 57 | } 58 | 59 | return null; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/callrouter/logging.php: -------------------------------------------------------------------------------- 1 | logging = $config['log'] ?? false; 26 | $this->loggingPath = $config['logPath'] ?: dirname(__DIR__, 2); 27 | } 28 | 29 | /** 30 | * set logging 31 | * 32 | * @param int $stringID 33 | * @param array $infos 34 | * @return void 35 | */ 36 | public function setLogging(int $stringID = null, array $infos = []) 37 | { 38 | if ($stringID == 0) { 39 | $message = sprintf('Guarding started: listen to FRITZ!Box call monitor at %s', $infos[0]); 40 | } elseif ($stringID == 1) { 41 | $message = sprintf('Phone book %s (re-)loaded; next refresh: %s', $infos[0], $infos[1]); 42 | } elseif ($stringID == 2) { 43 | $message = sprintf('CALL IN from number %s to MSN %s', $infos[0], $infos[1]); 44 | } elseif ($stringID == 3) { 45 | $message = sprintf('Caller uses an unknown country code! Added to spam phone book #%s', $infos[1]); 46 | } elseif ($stringID == 4) { 47 | $message = sprintf('Foreign number. Added to spam phone book #%s', $infos[1]); 48 | } elseif ($stringID == 5) { 49 | $message = sprintf('Caller uses a nonexistent area code! Added to spam phone book #%s', $infos[1]); 50 | } elseif ($stringID == 6) { 51 | $message = sprintf('Caller has a bad reputation (%s/%s)! Added to spam phone book #%s', $infos[2], $infos[3], $infos[1]); 52 | } elseif ($stringID == 7) { 53 | $message = sprintf('Caller has a rating of %s out of 9 and %s valuations.', $infos[0], $infos[1]); 54 | } elseif ($stringID == 8) { 55 | $message = sprintf('Phone book %s could not be read', $infos[0]); 56 | } elseif ($stringID == 9) { 57 | $message = sprintf('Caller is using an illegal subscriber number! Added to spam phone book #%s', $infos[1]); 58 | } elseif ($stringID == 10) { 59 | $message = sprintf('Called number identified as: %s. Entry added to phone book #%s', $infos[0], $infos[1]); 60 | } elseif ($stringID == 11) { 61 | $message = sprintf('Number traced in: %s', $infos[0]); 62 | } elseif ($stringID == 12) { 63 | $message = sprintf('CALL OUT to number %s', $infos[0]); 64 | } elseif ($stringID == 13) { 65 | $message = sprintf('Number length is not in range. Added to spam phone book #%s', $infos[1]); 66 | } elseif ($stringID == 14) { 67 | $message = sprintf('Caller transmits an unusual number combination. Added to spam phone book #%s', $infos[1]); 68 | } elseif ($stringID == 15) { 69 | $message = sprintf('Derived actual phone number: %s Added to spam phone book #%s', $infos[2], $infos[1]); 70 | } elseif ($stringID == 16) { 71 | $message = sprintf('User provided phone number: %s Added to spam phone book #%s', $infos[2], $infos[1]); 72 | } elseif ($stringID == 17) { 73 | $message = sprintf('Network provided phone number: %s Added to spam phone book #%s', $infos[2], $infos[1]); 74 | } elseif ($stringID == 99) { 75 | $message = $infos[0]; 76 | } 77 | 78 | return $this->writeLogging($message); 79 | } 80 | 81 | /** 82 | * write logging info 83 | * 84 | * @param string $info 85 | * @return string $message 86 | */ 87 | private function writeLogging ($info) 88 | { 89 | date_default_timezone_set('Europe/Berlin'); 90 | $message = date('d.m.Y H:i:s') . ' => ' . $info . PHP_EOL; 91 | if ($this->logging) { 92 | if (getenv('DOCKER_CONTAINER')) { 93 | echo($info . PHP_EOL); 94 | } else { 95 | file_put_contents($this->loggingPath . '/callrouter_logging.txt', $message, FILE_APPEND); 96 | } 97 | } 98 | 99 | return $message; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/callrouter/phonetools.php: -------------------------------------------------------------------------------- 1 | 'neuartige Dienste', 31 | '137' => 'Televoting, Gewinnspiel', 32 | '138' => 'Televoting, Gewinnspiel', 33 | '164' => 'Pager', 34 | '168' => 'Cityruf/Pager', 35 | '169' => 'Cityruf/Pager', 36 | '18' => 'Service-Dienste', 37 | '190' => 'Mehrwertdienste', */ 38 | '3212' => 'nationale Teilnehmernummer', 39 | '3221' => 'nationale Teilnehmernummer', 40 | '3222' => 'nationale Teilnehmernummer', 41 | '700' => 'persönliche Rufnummer', 42 | '800' => 'Mehrwertdienste', 43 | /* '900' => 'Mehrwertdienste', 44 | '902' => 'Televoting, Gewinnspiel', */ 45 | ]; 46 | 47 | private 48 | $fritzContact, // SOAP client 49 | $fritzVoIP, // SOAP client 50 | $prefixes = [], // area codes incl. mobile codes ($cellular) 51 | $cellular = [], // cellular prefixes 52 | $countryCodes = [], // country codes 53 | $ownAreaCode, 54 | $fritzBoxPhoneBooks = []; 55 | 56 | /** 57 | * @param array $config 58 | * @return void 59 | */ 60 | public function __construct(array $fritzBox) 61 | { 62 | $this->fritzContact = new x_contact($fritzBox['url'], $fritzBox['user'], $fritzBox['password']); 63 | $this->fritzBoxPhoneBooks = $this->getPhonebookList(); 64 | $this->fritzVoIP = new x_voip($fritzBox['url'], $fritzBox['user'], $fritzBox['password']); 65 | $this->ownAreaCode = $this->fritzVoIP->getVoIPCommonAreaCode(); 66 | $this->getPhoneCodes(); 67 | $this->getCountryCodes(); 68 | } 69 | 70 | /** 71 | * returns list of phone books 72 | * 73 | * @return array 74 | */ 75 | public function getPhoneBookList() 76 | { 77 | return explode(',', $this->fritzContact->getPhonebookList()); 78 | } 79 | 80 | /** 81 | * returns URL data 82 | * 83 | * @return array 84 | */ 85 | public function getURL() 86 | { 87 | return $this->fritzContact->getURL(); 88 | } 89 | 90 | /** 91 | * get a fresh x_contact client with new SID 92 | * 93 | * @return void 94 | */ 95 | public function refreshContactClient() 96 | { 97 | $this->fritzContact->getClient(); 98 | } 99 | 100 | /** 101 | * returns area code from FRITZ!Box settings 102 | * 103 | * @return array 104 | */ 105 | public function getOwnAreaCode() 106 | { 107 | return [ 108 | 'code' => $this->ownAreaCode, 109 | 'length' => strlen($this->ownAreaCode) 110 | ]; 111 | } 112 | 113 | /** 114 | * checks phone book indices against list of phone books from FRITZ!Box 115 | * 116 | * @param array $phoneBooks to proof 117 | * @return void 118 | */ 119 | public function checkListOfPhoneBooks(array $phoneBooks) 120 | { 121 | $message = ''; 122 | foreach ($phoneBooks as $phoneBook) { 123 | if (!in_array($phoneBook, $this->fritzBoxPhoneBooks)) { 124 | $message = sprintf('Could not find phone book #%s on FRITZ!Box!', $phoneBook); 125 | } 126 | } 127 | if (!empty($message)) { 128 | throw new \Exception($message); 129 | } 130 | } 131 | 132 | /** 133 | * get numbers from a phone book 134 | * 135 | * @param int $phonebookID 136 | * @return array 137 | */ 138 | public function getPhoneNumbers(int $phonebookID = 0) 139 | { 140 | $phoneBook = $this->fritzContact->getPhonebook($phonebookID); 141 | if ($phoneBook == false) { 142 | return []; 143 | } 144 | 145 | return $this->fritzContact->getListOfPhoneNumbers($phoneBook); 146 | } 147 | 148 | /** 149 | * set new entry in phonebook 150 | * 151 | * @param array $entry 152 | * @return void 153 | */ 154 | public function setPhoneBookEntry(array $entry) 155 | { 156 | $this->fritzContact->getClient(); 157 | $this->fritzContact->setContact( 158 | $entry['phonebook'], 159 | $entry['name'], 160 | $entry['number'], 161 | $entry['type'] 162 | ); 163 | } 164 | 165 | /** 166 | * Clean up test number string to ensure no characters other than those are 167 | * used by the call monitor 168 | * 169 | * @param string $number 170 | * @return string $number 171 | */ 172 | public function sanitizeNumber(string $number): string 173 | { 174 | return $this->fritzContact->sanitizeNumber($number); 175 | } 176 | 177 | /** 178 | * get an array, where the area code (ONB) is key and area name is value 179 | * ONB stands for OrtsNetzBereich(e) 180 | * 181 | * @return array 182 | */ 183 | private function getAreaCodes() 184 | { 185 | if (!$onbData = file(dirname(__DIR__, 2) . self::ONB_SOURCE)) { 186 | throw new \exception('Could not read ONB data from local file!'); 187 | } 188 | $areaCodes = []; 189 | end($onbData) != "\x1a" ?: array_pop($onbData); // usually file comes with this char at eof 190 | $rows = array_map(function($row) { return str_getcsv($row, self::DELIMITER); }, $onbData); 191 | array_shift($rows); // delete header 192 | foreach($rows as $row) { 193 | ($row[2] == 0) ?: $areaCodes[$row[0]] = $row[1]; // only use active ONBs ("1") 194 | } 195 | 196 | return $areaCodes; 197 | } 198 | 199 | /** 200 | * sets the arrays with area codes and codes of mobile providers: from 201 | * longest and highest key [39999] to the shortest [30] 202 | * 203 | * @return void 204 | */ 205 | private function getPhoneCodes() 206 | { 207 | require_once (self::CELLULAR); 208 | 209 | $this->cellular = $cellularNumbers; // we need them also seperatly 210 | $this->prefixes = $this->getAreaCodes() + $this->cellular + self::SRVCSNMBR; 211 | krsort($this->prefixes, SORT_NUMERIC); 212 | } 213 | 214 | /** 215 | * sets the array with country codes: from longest and highest key [7979] 216 | * (Russia) to the shortest [1] (USA) 217 | * 218 | * @return void 219 | */ 220 | private function getCountryCodes() 221 | { 222 | require_once (self::COUNTRYCDS); 223 | 224 | $this->countryCodes = $countryCodes; 225 | krsort($this->countryCodes, SORT_NUMERIC); 226 | } 227 | 228 | /** 229 | * returns the prefixes 230 | * 231 | * @return array $prefixes 232 | */ 233 | public function getPrefixes() 234 | { 235 | return $this->prefixes; 236 | } 237 | 238 | /** 239 | * return the area code and subscribers number from a phone number: 240 | * ['prefix'] => area code 241 | * ['designation'] => area name (~NDC: national destination code) 242 | * ['subscriber'] => subscribers number (base number plus direct dial in) 243 | * 244 | * Germany currently has around 5200 area codes, length between two and five 245 | * digits (except the leading zero). 246 | * @see https://de.wikipedia.org/wiki/Rufnummer#/media/Datei:Telefonnummernaufbau.png 247 | * 248 | * @param string $phoneNumber to extract the area code from 249 | * @return array|bool $area code data or false 250 | */ 251 | public function getArea(string $phoneNumber) 252 | { 253 | for ($i = 5; $i > 1; $i--) { 254 | $needle = substr($phoneNumber, 1, $i); 255 | if (isset($this->prefixes[$needle])) { 256 | return [ 257 | 'prefix' => $needle, 258 | 'designation' => $this->prefixes[$needle], 259 | 'subscriber' => substr($phoneNumber, 1 + $i), 260 | ]; 261 | } 262 | } 263 | 264 | return false; 265 | } 266 | 267 | /** 268 | * return the country code and country from a phone number: 269 | * ['countrycode'] => country code 270 | * ['country'] => country name 271 | * ['national'] => NSN: national significant number 272 | * @see https://de.wikipedia.org/wiki/Rufnummer#/media/Datei:Telefonnummernaufbau.png 273 | * 274 | * @param string $phoneNumber to identify the country code from 275 | * @return array|bool $country code data or false 276 | */ 277 | public function getCountry(string $phoneNumber) 278 | { 279 | for ($i = 4; $i > 0; $i--) { 280 | $needle = substr($phoneNumber, 2, $i); 281 | if (isset($this->countryCodes[$needle])) { 282 | $countryCode = '00' . $needle; 283 | if ($this->countryCodes[$needle] == 'Kanada') { 284 | $countryCode = '001'; 285 | } elseif (substr($needle, 0, 1) == '7') { 286 | $countryCode = '007'; 287 | } 288 | return [ 289 | 'countrycode' => $countryCode, 290 | 'country' => $this->countryCodes[$needle], 291 | 'national' => substr($phoneNumber, strlen($countryCode)), 292 | ]; 293 | } 294 | } 295 | 296 | return false; 297 | } 298 | 299 | /** 300 | * returns true if prefix is a cellular code 301 | * 302 | * @param string $prefix 303 | * @return bool 304 | */ 305 | public function isCellularCode($prefix) 306 | { 307 | return isset($this->cellular[$prefix]); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- 1 | getCallMonitorStream(); 19 | // uncomment next line for debugging 20 | // $values = $callRouter->setDebugStream(); 21 | if ($values['type'] == 'RING') { // inbound call 22 | // start test case injection (if you use the -t option) 23 | empty($testNumbers) ?: $callRouter->getTestInjection(); 24 | $callRouter->runInboundValidation(); // central validation routine 25 | } elseif ($values['type'] == 'CALL') { 26 | $callRouter->runOutboundValidation(); 27 | } elseif ($values['type'] != null) { 28 | $callRouter->setLogging(99, [$values['type']]); 29 | } 30 | $callRouter->sendMail(); 31 | $callRouter->refreshSocket(); // do life support during idle 32 | $callRouter->refreshPhonebooks(); 33 | $callRouter->setCallMonitorValues(); 34 | } 35 | } 36 | --------------------------------------------------------------------------------