├── .github └── workflows │ ├── dockerimage-gammu.yml │ └── dockerimage.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── Versions.kt ├── examples ├── android │ ├── .gitignore │ ├── config │ │ └── application.yml │ ├── data │ │ └── .gitignore │ ├── docker-compose.yml │ └── synapse │ │ ├── .gitignore │ │ ├── homeserver.yaml │ │ ├── matrix-kotlin-sdk-it-synapse.log.config │ │ ├── matrix-kotlin-sdk-it-synapse.signing.key │ │ └── sms-bridge-appservice.yaml └── gammu │ ├── config │ ├── application.yml │ └── gammu-smsdrc │ ├── data │ └── .gitignore │ ├── docker-compose.yml │ └── synapse │ ├── .gitignore │ ├── homeserver.yaml │ ├── matrix-kotlin-sdk-it-synapse.log.config │ ├── matrix-kotlin-sdk-it-synapse.signing.key │ └── sms-bridge-appservice.yaml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src ├── main ├── docker │ └── gammu │ │ ├── Dockerfile │ │ └── supervisord.conf ├── kotlin │ └── net │ │ └── folivo │ │ └── matrix │ │ └── bridge │ │ └── sms │ │ ├── SmsBridgeApplication.kt │ │ ├── SmsBridgeDatabaseConfiguration.kt │ │ ├── SmsBridgeProperties.kt │ │ ├── appservice │ │ ├── SmsMatrixAppserviceRoomService.kt │ │ ├── SmsMatrixAppserviceUserService.kt │ │ └── SmsMatrixMembershipChangeService.kt │ │ ├── handler │ │ ├── MessageToBotHandler.kt │ │ ├── MessageToSmsHandler.kt │ │ ├── PhoneNumberService.kt │ │ ├── ReceiveSmsService.kt │ │ ├── SmsAbortCommand.kt │ │ ├── SmsAbortCommandHandler.kt │ │ ├── SmsBotConsole.kt │ │ ├── SmsCommand.kt │ │ ├── SmsInviteCommand.kt │ │ ├── SmsInviteCommandHandler.kt │ │ ├── SmsMessageHandler.kt │ │ ├── SmsSendCommand.kt │ │ └── SmsSendCommandHandler.kt │ │ ├── mapping │ │ ├── MatrixSmsMapping.kt │ │ ├── MatrixSmsMappingRepository.kt │ │ └── MatrixSmsMappingService.kt │ │ ├── message │ │ ├── MatrixMessage.kt │ │ ├── MatrixMessageReceiver.kt │ │ ├── MatrixMessageReceiverRepository.kt │ │ ├── MatrixMessageRepository.kt │ │ ├── MatrixMessageService.kt │ │ └── MessageQueueHandler.kt │ │ └── provider │ │ ├── SmsProvider.kt │ │ ├── android │ │ ├── AndroidInSmsMessage.kt │ │ ├── AndroidInSmsMessagesResponse.kt │ │ ├── AndroidOutSmsMessage.kt │ │ ├── AndroidOutSmsMessageRepository.kt │ │ ├── AndroidOutSmsMessageRequest.kt │ │ ├── AndroidSmsProcessed.kt │ │ ├── AndroidSmsProcessedRepository.kt │ │ ├── AndroidSmsProvider.kt │ │ ├── AndroidSmsProviderConfiguration.kt │ │ ├── AndroidSmsProviderException.kt │ │ ├── AndroidSmsProviderLauncher.kt │ │ └── AndroidSmsProviderProperties.kt │ │ └── gammu │ │ ├── GammuSmsProvider.kt │ │ └── GammuSmsProviderProperties.kt └── resources │ ├── application-initialsync.yml │ ├── application.yml │ ├── banner.txt │ └── db │ └── changelog │ ├── net.folivo.matrix.bridge.sms.changelog-0.4.0.RELEASE.yaml │ ├── net.folivo.matrix.bridge.sms.changelog-0.4.2.RELEASE.yaml │ ├── net.folivo.matrix.bridge.sms.changelog-0.5.0.yaml │ └── net.folivo.matrix.bridge.sms.changelog-master.yml └── test ├── kotlin └── net │ └── folivo │ └── matrix │ └── bridge │ └── sms │ ├── KotestConfig.kt │ ├── appservice │ ├── SmsMatrixAppserviceRoomServiceTest.kt │ ├── SmsMatrixAppserviceUserServiceTest.kt │ └── SmsMatrixMembershipChangeServiceTest.kt │ ├── handler │ ├── MessageToBotHandlerTest.kt │ ├── MessageToSmsHandlerTest.kt │ ├── PhoneNumberServiceTest.kt │ ├── ReceiveSmsServiceTest.kt │ ├── SmsAbortCommandHandlerTest.kt │ ├── SmsAbortCommandTest.kt │ ├── SmsBotConsoleTest.kt │ ├── SmsInviteCommandHandlerTest.kt │ ├── SmsInviteCommandTest.kt │ ├── SmsMessageHandlerTest.kt │ ├── SmsSendCommandHandlerTest.kt │ └── SmsSendCommandTest.kt │ ├── mapping │ ├── MatrixSmsMappingRepositoryTest.kt │ └── MatrixSmsMappingServiceTest.kt │ ├── message │ ├── MatrixMessageRepositoryTest.kt │ └── MatrixMessageServiceTest.kt │ └── provider │ └── android │ ├── AndroidSmsProviderLauncherTest.kt │ └── AndroidSmsProviderTest.kt └── resources └── application.yml /.github/workflows/dockerimage-gammu.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI with bundled gammu 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | publish-image: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Java 13 | uses: actions/setup-java@v1 14 | with: 15 | java-version: 11 16 | - uses: azure/docker-login@v1 17 | with: 18 | username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 20 | - name: build image 21 | run: ./gradlew docker-gammu --debug --full-stacktrace 22 | - name: Publish version tagged image to DockerHub 23 | run: docker push folivonet/matrix-sms-bridge:${GITHUB_REF:11}-gammu 24 | - name: Publish latest image to DockerHub 25 | run: docker push folivonet/matrix-sms-bridge:latest-gammu -------------------------------------------------------------------------------- /.github/workflows/dockerimage.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | publish-image: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Java 13 | uses: actions/setup-java@v1 14 | with: 15 | java-version: 11 16 | - uses: azure/docker-login@v1 17 | with: 18 | username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 20 | - name: build image 21 | run: ./gradlew bootBuildImage --debug --full-stacktrace 22 | - name: tag to version 23 | run: docker tag folivonet/matrix-sms-bridge:latest folivonet/matrix-sms-bridge:${GITHUB_REF:11} 24 | - name: Publish version tagged image to DockerHub 25 | run: docker push folivonet/matrix-sms-bridge:${GITHUB_REF:11} 26 | - name: Publish latest image to DockerHub 27 | run: docker push folivonet/matrix-sms-bridge:latest 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | db/ 5 | !gradle/wrapper/gradle-wrapper.jar 6 | !**/src/main/** 7 | !**/src/test/** 8 | 9 | ### STS ### 10 | .apt_generated 11 | .classpath 12 | .factorypath 13 | .project 14 | .settings 15 | .springBeans 16 | .sts4-cache 17 | 18 | ### IntelliJ IDEA ### 19 | .idea 20 | *.iws 21 | *.iml 22 | *.ipr 23 | out/ 24 | 25 | ### NetBeans ### 26 | /nbproject/private/ 27 | /nbbuild/ 28 | /dist/ 29 | /nbdist/ 30 | /.nb-gradle/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Docker Image CI](https://github.com/benkuly/matrix-sms-bridge/workflows/Docker%20Image%20CI/badge.svg) 2 | 3 | # matrix-sms-bridge 4 | 5 | This is a matrix bridge, which allows you to bridge matrix rooms to SMS with one telephone number only. It is build on 6 | top of [matrix-spring-boot-sdk](https://github.com/benkuly/matrix-spring-boot-sdk) and written in kotlin. 7 | 8 | You need help? Ask your questions in [#matrix-sms-bridge:imbitbu.de](https://matrix.to/#/#matrix-sms-bridge:imbitbu.de) 9 | 10 | Features: 11 | 12 | * use with one outgoing telephone number only 13 | * send SMS 14 | * receive SMS 15 | * use room aliases to have one room for each incoming telephone number 16 | * bot for automated sms sending 17 | * creates rooms for you 18 | * writes messages for you 19 | * allows you to send SMS at a specific time (in future) 20 | * invites users for you, when room gets created 21 | * provider: 22 | * Android Smartphone with [android-sms-gateway-server](https://github.com/RebekkaMa/android-sms-gateway-server) 23 | * modem (with [Gammu](https://github.com/gammu/gammu)) -> not actively maintained anymore 24 | 25 | ## User Guide 26 | 27 | ### Automated room creation 28 | 29 | Create a room with you and `@smsBot:yourHomeServer.org` only. Now you can write `sms send --help` which gives you a 30 | help, how to use this command. 31 | 32 | Example: `sms send -t 01749292923 "Hello World"` creates a new room with the telephone number and writes "Hello World" 33 | for you. If there already is a room with this telephone number, and you are participating, then "Hello World" will be 34 | sent to that room. 35 | 36 | ### Invite telephone number to matrix room 37 | 38 | The virtual matrix users, which represents SMS numbers, have the following pattern: 39 | 40 | ```text 41 | @sms_49123456789:yourHomeServer.org 42 | ``` 43 | 44 | The number `49123456789` represents the international german telephone number `+49123456789`. 45 | 46 | You can invite these users to every room, independently of the room members. So you can also invite more than one SMS 47 | number to rooms with more than one real matrix users. 48 | 49 | ### Write to telephone numbers 50 | 51 | After a room invite the virtual matrix users automatically join the room and every message to this room will be sent as 52 | SMS to the telephone number. The SMS contains a token (e.g. "#3"), which can be used in the answer of the SMS to route 53 | it back to the matrix room. 54 | 55 | ### What if the SMS user has no token? 56 | 57 | The bridge can be configured to route all SMS without a valid token to a default matrix room. Note that you must 58 | invite `@smsbot:yourHomeServer` to this room. 59 | 60 | ## Admin Guide 61 | 62 | ### Configure Application Service 63 | 64 | The Application Service gets configured with a yaml-file: 65 | 66 | ```yaml 67 | matrix: 68 | bridge: 69 | sms: 70 | # (optional) SMS messages without a valid token are routed to this room. 71 | # Note that you must invite @smsbot:yourHomeServer to this room. 72 | defaultRoomId: "!jNkGzAFIPWxxXLmhso:matrix-local" 73 | # (optional) Allows to map SMS messages without token, when there is only one room with this number. Default is true. 74 | # allowMappingWithoutToken: true 75 | # The default region to use for telephone numbers. 76 | defaultRegion: DE 77 | # (optional) The default timezone to use for `sms send` `-a` argument 78 | defaultTimeZone: Europe/Berlin 79 | # (optional) Allows you to enable one room alias for one telephone number. Default is false. 80 | # singleModeEnabled: false 81 | # (optional) In this section you can override the default templates. 82 | # templates: 83 | # See SmsBridgeProperties.kt for the keys. 84 | bot: 85 | # The domain-part of matrix-ids. E. g. example.org when your userIds look like @unicorn:example.org 86 | serverName: matrix-local 87 | # Database settings 88 | migration: 89 | url: jdbc:h2:file:/data/db/testdb 90 | username: sa 91 | database: 92 | url: r2dbc:h2:file:////data/db/testdb 93 | username: sa 94 | client: 95 | homeServer: 96 | # The hostname of your Homeserver. 97 | hostname: matrix-synapse 98 | # (optional) The port of your Homeserver. Default is 443. 99 | port: 8008 100 | # (optional) Use http or https. Default is true (so uses https). 101 | secure: false 102 | # The token to authenticate against the Homeserver. 103 | token: asToken 104 | appservice: 105 | # A unique token for Homeservers to use to authenticate requests to this application service. 106 | hsToken: hsToken 107 | ``` 108 | 109 | ### Configure HomeServer 110 | 111 | Add this to your synapse `homeserver.yaml`: 112 | 113 | ```yaml 114 | app_service_config_files: 115 | - /path/to/sms-bridge-appservice.yaml 116 | ``` 117 | 118 | `sms-bridge-appservice.yaml` looks like: 119 | 120 | ```yaml 121 | id: "SMS Bridge" 122 | url: "http://url-to-sms-bridge:8080" 123 | as_token: asToken 124 | hs_token: hsToken 125 | sender_localpart: "smsbot" 126 | namespaces: 127 | users: 128 | - exclusive: true 129 | regex: "^@sms_.+:yourHomeServerDomain$" 130 | aliases: 131 | - exclusive: true 132 | regex: "^#sms_.+:yourHomeServerDomain$" 133 | rooms: [] 134 | ``` 135 | 136 | ### Configure Provider 137 | 138 | If you want your SMS gateway provider to be supported, look into the 139 | package [`provider`](./src/main/kotlin/net/folivo/matrix/bridge/sms/provider) to see how you can add your own provider 140 | to this bridge. 141 | 142 | #### android-sms-gateway-server 143 | 144 | You need to add some properties to the Application Service yaml-file: 145 | 146 | ```yaml 147 | matrix: 148 | bridge: 149 | sms: 150 | provider: 151 | android: 152 | # (optional) default is disabled 153 | enabled: true 154 | # The url to the android-sms-gateway-server 155 | baseUrl: https://192.168.25.26:9090 156 | # The username of the gateway 157 | username: admin 158 | # The password of the gateway 159 | password: 123 160 | # (optional) if you use a self signed certificate, you can add the public key here 161 | trustStore: 162 | path: /data/matrix-sms-bridge-android.p12 163 | password: 123 164 | type: PKCS12 165 | ``` 166 | 167 | #### Gammu 168 | 169 | First you need to add some properties to the Application Service yaml-file: 170 | 171 | ```yaml 172 | matrix: 173 | bridge: 174 | sms: 175 | provider: 176 | gammu: 177 | # (optional) default is disabled 178 | enabled: true 179 | # (optional) Path to the Gammu-Inbox directory. Default is "/data/spool/inbox". 180 | inboxPath: "/data/spool/inbox" 181 | # (optional) Path to the directory, where to put processed messages. Default is "/data/spool/inbox_processed". 182 | inboxProcessedPath: "/data/spool/inbox_processed" 183 | ``` 184 | 185 | Your `gammu-smsdrc` should look like this: 186 | 187 | ```text 188 | [gammu] 189 | Device = /dev/ttyModem 190 | LogFile = /data/log/gammu.log 191 | debugLevel = 1 192 | 193 | [smsd] 194 | Service = files 195 | LoopSleep = 3 196 | InboxPath = /data/spool/inbox/ 197 | OutboxPath = /data/spool/outbox/ 198 | SentSMSPath = /data/spool/sent/ 199 | ErrorSMSPath = /data/spool/error/ 200 | InboxFormat = detail 201 | OutboxFormat = detail 202 | TransmitFormat = auto 203 | debugLevel = 1 204 | LogFile = /data/log/smsd.log 205 | DeliveryReport = log 206 | DeliveryReportDelay = 7200 207 | HangupCalls = 1 208 | CheckBattery = 0 209 | ``` 210 | 211 | ### Using Docker container 212 | 213 | There are two types of docker-containers. One, that is bundled with Gammu and one without: 214 | 215 | * Default: `docker pull folivonet/matrix-sms-bridge:latest` 216 | * Containers bundled with gammu use tags with the 217 | suffix `-gammu`: `docker pull folivonet/matrix-sms-bridge:latest-gammu` 218 | 219 | To see, how a docker setup of the bridge could look like, have a look at the [examples](./examples). 220 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | import org.springframework.boot.gradle.tasks.bundling.BootBuildImage 3 | 4 | plugins { 5 | base 6 | id("org.springframework.boot") version Versions.springBoot 7 | id("io.spring.dependency-management") version Versions.springDependencyManagement 8 | kotlin("jvm") version Versions.kotlin 9 | kotlin("kapt") version Versions.kotlin 10 | kotlin("plugin.spring") version Versions.kotlin 11 | } 12 | 13 | repositories { 14 | mavenCentral() 15 | mavenLocal() 16 | maven("https://repo.spring.io/milestone") 17 | } 18 | 19 | group = "net.folivo" 20 | version = "0.5.9" 21 | java.sourceCompatibility = JavaVersion.VERSION_11 22 | 23 | tasks.withType() { 24 | manifest { 25 | attributes( 26 | "Implementation-Title" to "matrix-sms-bridge", 27 | "Implementation-Version" to project.version 28 | ) 29 | } 30 | } 31 | 32 | 33 | dependencies { 34 | implementation("org.jetbrains.kotlin:kotlin-reflect") 35 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") 36 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 37 | 38 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") 39 | implementation("com.michael-bull.kotlin-retry:kotlin-retry:${Versions.kotlinRetry}") 40 | 41 | implementation("net.folivo:matrix-spring-boot-bot:${Versions.matrixSDK}") 42 | 43 | implementation("com.github.ajalt.clikt:clikt:${Versions.clikt}") 44 | implementation("org.apache.ant:ant:${Versions.ant}") { 45 | exclude(group = "org.apache.ant", module = "ant-launcher") 46 | } 47 | implementation("com.googlecode.libphonenumber:libphonenumber:${Versions.libphonenumber}") 48 | 49 | implementation("io.r2dbc:r2dbc-h2") 50 | implementation("com.h2database:h2") 51 | 52 | annotationProcessor("org.springframework.boot:spring-boot-autoconfigure-processor") 53 | annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") 54 | 55 | testImplementation("io.kotest:kotest-runner-junit5:${Versions.kotest}") 56 | testImplementation("io.kotest:kotest-assertions-core:${Versions.kotest}") 57 | testImplementation("io.kotest:kotest-property:${Versions.kotest}") 58 | testImplementation("io.kotest:kotest-extensions-spring:${Versions.kotest}") 59 | testImplementation("io.kotest:kotest-extensions-mockserver:${Versions.kotest}") 60 | testImplementation("com.ninja-squad:springmockk:${Versions.springMockk}") 61 | testImplementation("io.projectreactor:reactor-test") 62 | 63 | testImplementation("com.squareup.okhttp3:mockwebserver") 64 | 65 | testImplementation("org.springframework.boot:spring-boot-starter-test") { 66 | exclude(group = "org.junit.vintage", module = "junit-vintage-engine") 67 | exclude(group = "org.mockito", module = "mockito-core") 68 | exclude(group = "org.mockito", module = "mockito-junit-jupiter") 69 | } 70 | } 71 | 72 | // workaround for Clikt with Gradle 73 | configurations { 74 | productionRuntimeClasspath { 75 | attributes { 76 | attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) 77 | } 78 | } 79 | } 80 | 81 | tasks.withType { 82 | useJUnitPlatform() 83 | } 84 | 85 | tasks.withType { 86 | kotlinOptions { 87 | freeCompilerArgs = listOf("-Xjsr305=strict") 88 | jvmTarget = "11" 89 | } 90 | } 91 | 92 | tasks.getByName("bootBuildImage") { 93 | imageName = "folivonet/matrix-sms-bridge:latest" 94 | } 95 | 96 | tasks.register("docker-gammu") { 97 | group = "build" 98 | commandLine( 99 | "docker", 100 | "build", 101 | "--build-arg", 102 | "JAR_FILE=./build/libs/*.jar", 103 | "-t", 104 | "folivonet/matrix-sms-bridge:latest-gammu", 105 | "-t", 106 | "folivonet/matrix-sms-bridge:${project.version}-gammu", 107 | "-f", 108 | "./src/main/docker/gammu/Dockerfile", 109 | "." 110 | ) 111 | dependsOn("bootJar") 112 | } 113 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | mavenCentral() 7 | } -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/Versions.kt: -------------------------------------------------------------------------------- 1 | object Versions { 2 | const val kotlin = "1.4.31" // https://kotlinlang.org/ 3 | const val kotlinRetry = "1.0.8" // https://github.com/michaelbull/kotlin-retry 4 | const val kotest = "4.4.3" // https://github.com/kotest/kotest/releases 5 | const val springBoot = "2.4.4" // https://spring.io/projects/spring-boot 6 | const val springDependencyManagement = 7 | "1.0.11.RELEASE" // https://github.com/spring-gradle-plugins/dependency-management-plugin/releases 8 | const val springMockk = "2.0.3" // https://github.com/Ninja-Squad/springmockk/releases 9 | const val matrixSDK = "0.4.8" // https://github.com/benkuly/matrix-spring-boot-sdk/releases 10 | const val clikt = "3.0.1" // https://github.com/ajalt/clikt/releases 11 | const val ant = "1.10.9" // https://ant.apache.org/antnews.html 12 | const val libphonenumber = "8.12.12" // https://github.com/google/libphonenumber/releases 13 | } -------------------------------------------------------------------------------- /examples/android/.gitignore: -------------------------------------------------------------------------------- 1 | neo4j/ -------------------------------------------------------------------------------- /examples/android/config/application.yml: -------------------------------------------------------------------------------- 1 | logging: 2 | level: 3 | net.folivo.matrix: DEBUG 4 | 5 | matrix: 6 | bridge: 7 | sms: 8 | provider: 9 | android: 10 | enabled: true 11 | baseUrl: https://192.168.25.26:9090 12 | username: admin 13 | password: 123 14 | trustStore: 15 | path: /data/matrix-sms-bridge-android.p12 16 | password: 123 17 | type: PKCS12 18 | defaultRoomId: "!tRraLABExGeUDmOWvF:matrix-local" 19 | defaultRegion: DE 20 | defaultTimeZone: Europe/Berlin 21 | singleModeEnabled: true 22 | bot: 23 | serverName: matrix-local 24 | migration: 25 | url: jdbc:h2:file:/data/db/testdb 26 | username: sa 27 | database: 28 | url: r2dbc:h2:file:////data/db/testdb 29 | username: sa 30 | client: 31 | homeServer: 32 | hostname: matrix-synapse 33 | port: 8008 34 | secure: false 35 | token: 30c05ae90a248a4188e620216fa72e349803310ec83e2a77b34fe90be6081f46 36 | appservice: 37 | hsToken: 312df522183efd404ec1cd22d2ffa4bbc76a8c1ccf541dd692eef281356bb74e -------------------------------------------------------------------------------- /examples/android/data/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.txt 3 | *.smsbackup 4 | matrix-sms-bridge-android.p12 -------------------------------------------------------------------------------- /examples/android/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | matrix-sms-bridge: 4 | image: folivonet/matrix-sms-bridge:latest 5 | volumes: 6 | - type: bind 7 | source: ./config 8 | target: /config 9 | - type: bind 10 | source: ./data 11 | target: /data 12 | environment: 13 | - SPRING_CONFIG_ADDITIONAL_LOCATION=/config/application.yml 14 | #- SPRING_PROFILES_ACTIVE=initialsync 15 | expose: 16 | - 8080 17 | restart: on-failure 18 | matrix-synapse: 19 | image: matrixdotorg/synapse:latest 20 | volumes: 21 | - type: bind 22 | source: ./synapse 23 | target: /data 24 | environment: 25 | - SYNAPSE_REPORT_STATS=false 26 | - UID=1000 27 | - GID=1000 28 | depends_on: 29 | - matrix-sms-bridge 30 | ports: 31 | - 8008:8008 -------------------------------------------------------------------------------- /examples/android/synapse/.gitignore: -------------------------------------------------------------------------------- 1 | homeserver.db 2 | 3 | !.gitignore 4 | !homeserver.yaml 5 | !matrix-kotlin-sdk-it-synapse.log.config 6 | !matrix-kotlin-sdk-it-synapse.signing.key -------------------------------------------------------------------------------- /examples/android/synapse/matrix-kotlin-sdk-it-synapse.log.config: -------------------------------------------------------------------------------- 1 | 2 | version: 1 3 | 4 | formatters: 5 | precise: 6 | format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' 7 | 8 | filters: 9 | context: 10 | (): synapse.util.logcontext.LoggingContextFilter 11 | request: "" 12 | 13 | handlers: 14 | console: 15 | class: logging.StreamHandler 16 | formatter: precise 17 | filters: [context] 18 | 19 | loggers: 20 | synapse: 21 | level: WARNING 22 | 23 | synapse.storage.SQL: 24 | # beware: increasing this to DEBUG will make synapse log sensitive 25 | # information such as access tokens. 26 | level: WARNING 27 | 28 | rest_auth_provider: 29 | level: INFO 30 | 31 | root: 32 | level: WARNING 33 | handlers: [console] 34 | -------------------------------------------------------------------------------- /examples/android/synapse/matrix-kotlin-sdk-it-synapse.signing.key: -------------------------------------------------------------------------------- 1 | ed25519 a_EgjW U0b5hmg9zXoLxAZFVDLTvtggKw+vkZQepCgjL8ZYRfI 2 | -------------------------------------------------------------------------------- /examples/android/synapse/sms-bridge-appservice.yaml: -------------------------------------------------------------------------------- 1 | id: "SMS Bridge" 2 | url: "http://matrix-sms-bridge:8080" 3 | as_token: "30c05ae90a248a4188e620216fa72e349803310ec83e2a77b34fe90be6081f46" 4 | hs_token: "312df522183efd404ec1cd22d2ffa4bbc76a8c1ccf541dd692eef281356bb74e" 5 | sender_localpart: "smsbot" 6 | namespaces: 7 | users: 8 | - exclusive: true 9 | regex: "^@sms_.+:matrix-local" 10 | aliases: 11 | - exclusive: true 12 | regex: "^#sms_.+:matrix-local" 13 | rooms: [ ] -------------------------------------------------------------------------------- /examples/gammu/config/application.yml: -------------------------------------------------------------------------------- 1 | logging: 2 | level: 3 | net.folivo.matrix: DEBUG 4 | 5 | matrix: 6 | bridge: 7 | sms: 8 | provider: 9 | gammu: 10 | enabled: true 11 | defaultRoomId: "!tRraLABExGeUDmOWvF:matrix-local" 12 | defaultRegion: DE 13 | defaultTimeZone: Europe/Berlin 14 | singleModeEnabled: true 15 | bot: 16 | serverName: matrix-local 17 | migration: 18 | url: jdbc:h2:file:/data/db/testdb 19 | username: sa 20 | database: 21 | url: r2dbc:h2:file:////data/db/testdb 22 | username: sa 23 | client: 24 | homeServer: 25 | hostname: matrix-synapse 26 | port: 8008 27 | secure: false 28 | token: 30c05ae90a248a4188e620216fa72e349803310ec83e2a77b34fe90be6081f46 29 | appservice: 30 | hsToken: 312df522183efd404ec1cd22d2ffa4bbc76a8c1ccf541dd692eef281356bb74e -------------------------------------------------------------------------------- /examples/gammu/config/gammu-smsdrc: -------------------------------------------------------------------------------- 1 | [gammu] 2 | Device = /dev/ttyModem 3 | LogFile = /data/log/gammu.log 4 | debugLevel = 1 5 | 6 | [smsd] 7 | Service = files 8 | LoopSleep = 3 9 | InboxPath = /data/spool/inbox/ 10 | OutboxPath = /data/spool/outbox/ 11 | SentSMSPath = /data/spool/sent/ 12 | ErrorSMSPath = /data/spool/error/ 13 | InboxFormat = detail 14 | OutboxFormat = detail 15 | TransmitFormat = auto 16 | debugLevel = 1 17 | LogFile = /data/log/smsd.log 18 | DeliveryReport = log 19 | DeliveryReportDelay = 7200 20 | HangupCalls = 1 21 | CheckBattery = 0 -------------------------------------------------------------------------------- /examples/gammu/data/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.txt 3 | *.smsbackup -------------------------------------------------------------------------------- /examples/gammu/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | matrix-sms-bridge: 4 | image: folivonet/matrix-sms-bridge:latest 5 | volumes: 6 | - type: bind 7 | source: ./config 8 | target: /config 9 | - type: bind 10 | source: ./config/gammu-smsdrc 11 | target: /etc/gammu-smsdrc 12 | - type: bind 13 | source: ./data 14 | target: /data 15 | #devices: 16 | #- "/dev/ttyUSB1:/dev/ttyModem" 17 | environment: 18 | - SPRING_CONFIG_ADDITIONAL_LOCATION=/config/application.yml 19 | - GAMMU_CONFIG=/config/gammu-smsdrc 20 | #- SPRING_PROFILES_ACTIVE=initialsync 21 | expose: 22 | - 8080 23 | restart: on-failure 24 | matrix-synapse: 25 | image: matrixdotorg/synapse:latest 26 | volumes: 27 | - type: bind 28 | source: ./synapse 29 | target: /data 30 | environment: 31 | - SYNAPSE_REPORT_STATS=false 32 | - UID=1000 33 | - GID=1000 34 | depends_on: 35 | - matrix-sms-bridge 36 | ports: 37 | - 8008:8008 -------------------------------------------------------------------------------- /examples/gammu/synapse/.gitignore: -------------------------------------------------------------------------------- 1 | homeserver.db 2 | 3 | !.gitignore 4 | !homeserver.yaml 5 | !matrix-kotlin-sdk-it-synapse.log.config 6 | !matrix-kotlin-sdk-it-synapse.signing.key -------------------------------------------------------------------------------- /examples/gammu/synapse/matrix-kotlin-sdk-it-synapse.log.config: -------------------------------------------------------------------------------- 1 | 2 | version: 1 3 | 4 | formatters: 5 | precise: 6 | format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' 7 | 8 | filters: 9 | context: 10 | (): synapse.util.logcontext.LoggingContextFilter 11 | request: "" 12 | 13 | handlers: 14 | console: 15 | class: logging.StreamHandler 16 | formatter: precise 17 | filters: [context] 18 | 19 | loggers: 20 | synapse: 21 | level: WARNING 22 | 23 | synapse.storage.SQL: 24 | # beware: increasing this to DEBUG will make synapse log sensitive 25 | # information such as access tokens. 26 | level: WARNING 27 | 28 | rest_auth_provider: 29 | level: INFO 30 | 31 | root: 32 | level: WARNING 33 | handlers: [console] 34 | -------------------------------------------------------------------------------- /examples/gammu/synapse/matrix-kotlin-sdk-it-synapse.signing.key: -------------------------------------------------------------------------------- 1 | ed25519 a_EgjW U0b5hmg9zXoLxAZFVDLTvtggKw+vkZQepCgjL8ZYRfI 2 | -------------------------------------------------------------------------------- /examples/gammu/synapse/sms-bridge-appservice.yaml: -------------------------------------------------------------------------------- 1 | id: "SMS Bridge" 2 | url: "http://matrix-sms-bridge:8080" 3 | as_token: "30c05ae90a248a4188e620216fa72e349803310ec83e2a77b34fe90be6081f46" 4 | hs_token: "312df522183efd404ec1cd22d2ffa4bbc76a8c1ccf541dd692eef281356bb74e" 5 | sender_localpart: "smsbot" 6 | namespaces: 7 | users: 8 | - exclusive: true 9 | regex: "^@sms_.+:matrix-local" 10 | aliases: 11 | - exclusive: true 12 | regex: "^#sms_.+:matrix-local" 13 | rooms: [ ] -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benkuly/matrix-sms-bridge/39567ccb17fe2775ba113ef4e22ff7c9b5c45d94/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.6-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ] ; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ] ; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 109 | MAX_FD_LIMIT=`ulimit -H -n` 110 | if [ $? -eq 0 ] ; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ] ; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin or MSYS, switch paths to Windows format before running java 129 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 132 | JAVACMD=`cygpath --unix "$JAVACMD"` 133 | 134 | # We build the pattern for arguments to be converted via cygpath 135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 136 | SEP="" 137 | for dir in $ROOTDIRSRAW ; do 138 | ROOTDIRS="$ROOTDIRS$SEP$dir" 139 | SEP="|" 140 | done 141 | OURCYGPATTERN="(^($ROOTDIRS))" 142 | # Add a user-defined pattern to the cygpath arguments 143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 145 | fi 146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 147 | i=0 148 | for arg in "$@" ; do 149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 151 | 152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 154 | else 155 | eval `echo args$i`="\"$arg\"" 156 | fi 157 | i=`expr $i + 1` 158 | done 159 | case $i in 160 | 0) set -- ;; 161 | 1) set -- "$args0" ;; 162 | 2) set -- "$args0" "$args1" ;; 163 | 3) set -- "$args0" "$args1" "$args2" ;; 164 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 165 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 166 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 167 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 168 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 169 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 170 | esac 171 | fi 172 | 173 | # Escape application args 174 | save () { 175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 176 | echo " " 177 | } 178 | APP_ARGS=`save "$@"` 179 | 180 | # Collect all arguments for the java command, following the shell quoting and substitution rules 181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 182 | 183 | exec "$JAVACMD" "$@" 184 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto init 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto init 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :init 68 | @rem Get command-line arguments, handling Windows variants 69 | 70 | if not "%OS%" == "Windows_NT" goto win9xME_args 71 | 72 | :win9xME_args 73 | @rem Slurp the command line arguments. 74 | set CMD_LINE_ARGS= 75 | set _SKIP=2 76 | 77 | :win9xME_args_slurp 78 | if "x%~1" == "x" goto execute 79 | 80 | set CMD_LINE_ARGS=%* 81 | 82 | :execute 83 | @rem Setup the command line 84 | 85 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 86 | 87 | @rem Execute Gradle 88 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 89 | 90 | :end 91 | @rem End local scope for the variables with windows NT shell 92 | if "%ERRORLEVEL%"=="0" goto mainEnd 93 | 94 | :fail 95 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 96 | rem the _cmd.exe /c_ return code! 97 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 98 | exit /b 1 99 | 100 | :mainEnd 101 | if "%OS%"=="Windows_NT" endlocal 102 | 103 | :omega 104 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "matrix-sms-bridge" 2 | -------------------------------------------------------------------------------- /src/main/docker/gammu/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:focal 2 | 3 | ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8' 4 | 5 | VOLUME ["/data", "/config"] 6 | 7 | RUN apt-get update && apt-get install -y \ 8 | locales \ 9 | openjdk-11-jre-headless \ 10 | gammu gammu-smsd \ 11 | supervisor 12 | 13 | RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen \ 14 | && locale-gen en_US.UTF-8 15 | 16 | RUN update-rc.d -f gammu-smsd remove 17 | 18 | COPY src/main/resources/application.yml /config-default/application.yml 19 | 20 | COPY src/main/docker/gammu/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 21 | 22 | RUN mkdir -p /var/log/supervisor 23 | 24 | EXPOSE 8080 25 | ARG JAR_FILE 26 | COPY ${JAR_FILE} app.jar 27 | 28 | ENV CONFIG_LOCATION /config/application.yml 29 | ENV GAMMU_CONFIG /config/gammu-smsdrc 30 | ENTRYPOINT ["/usr/bin/supervisord"] -------------------------------------------------------------------------------- /src/main/docker/gammu/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | logfile=/dev/stdout 4 | logfile_maxbytes=0 5 | logfile_backups=0 6 | 7 | [unix_http_server] 8 | file = /data/supervisord.sock 9 | 10 | [program:bridge] 11 | command=java -jar /app.jar --spring.config.location=/config-default/application.yml,%(ENV_CONFIG_LOCATION)s 12 | stdout_logfile=/dev/stdout 13 | stderr_logfile=/dev/stderr 14 | stdout_logfile_maxbytes=0 15 | stderr_logfile_maxbytes=0 16 | 17 | [program:gammu] 18 | command=gammu-smsd -c %(ENV_GAMMU_CONFIG)s 19 | stdout_logfile=/dev/stdout 20 | stderr_logfile=/dev/stderr 21 | stdout_logfile_maxbytes=0 22 | stderr_logfile_maxbytes=0 -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/SmsBridgeApplication.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.context.properties.EnableConfigurationProperties 5 | import org.springframework.boot.runApplication 6 | import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories 7 | 8 | @SpringBootApplication 9 | @EnableR2dbcRepositories 10 | @EnableConfigurationProperties(SmsBridgeProperties::class) 11 | class SmsBridgeApplication 12 | 13 | fun main(args: Array) { 14 | runApplication(*args) 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/SmsBridgeDatabaseConfiguration.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms 2 | 3 | import liquibase.integration.spring.SpringLiquibase 4 | import org.springframework.beans.factory.annotation.Qualifier 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.context.annotation.DependsOn 8 | import javax.sql.DataSource 9 | 10 | @Configuration 11 | class SmsBridgeDatabaseConfiguration { 12 | 13 | @Bean 14 | @DependsOn("liquibase") 15 | fun smsLiquibase(@Qualifier("liquibaseDatasource") liquibaseDatasource: DataSource): SpringLiquibase { 16 | return SpringLiquibase().apply { 17 | changeLog = "classpath:db/changelog/net.folivo.matrix.bridge.sms.changelog-master.yml" 18 | dataSource = liquibaseDatasource 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/SmsBridgeProperties.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms 2 | 3 | import net.folivo.matrix.core.model.MatrixId.RoomId 4 | import org.springframework.boot.context.properties.ConfigurationProperties 5 | import org.springframework.boot.context.properties.ConstructorBinding 6 | 7 | @ConfigurationProperties("matrix.bridge.sms") 8 | @ConstructorBinding 9 | data class SmsBridgeProperties( 10 | val templates: SmsBridgeTemplateProperties = SmsBridgeTemplateProperties(), 11 | val defaultRoomId: RoomId?, 12 | val allowMappingWithoutToken: Boolean = true, 13 | val singleModeEnabled: Boolean = false, 14 | val defaultRegion: String, 15 | val defaultLocalPart: String = "sms_", 16 | val defaultTimeZone: String = "UTC", 17 | val retryQueueDelay: Long = 30, 18 | ) { 19 | data class SmsBridgeTemplateProperties( 20 | val outgoingMessage: String = "{sender} wrote:\n\n{body}", 21 | val outgoingMessageFromBot: String = "{body}",//FIXME bad 22 | val outgoingMessageToken: String = "\n\nTo answer to this message add this token to your message: {token}", 23 | val answerInvalidTokenWithDefaultRoom: String? = null, 24 | val answerInvalidTokenWithoutDefaultRoom: String? = "Your message did not contain any valid token. Nobody can and will read your message.", 25 | val sendSmsError: String = "Could not send SMS: {error}", 26 | val sendSmsIncompatibleMessage: String = "Only text messages can be send to this SMS user.", 27 | val defaultRoomIncomingMessage: String = "{sender} wrote:\n\n{body}", 28 | val defaultRoomIncomingMessageWithSingleMode: String = "A message from {sender} was send to room {roomAlias}. Someone should join the room. Otherwise nobody will read the message.\n\nType `sms invite {roomAlias}` in a bot room to get invited to the room.", 29 | val botHelp: String = "To use this bot, type 'sms'", 30 | val botTooManyMembers: String = "Only rooms with two members are allowed to write with this bot.", 31 | val botSmsError: String = "There was an error while using sms command. Reason: {error}", 32 | val botSmsSendInvalidTelephoneNumber: String = "The telephone number is invalid.", 33 | val botSmsSendNewRoomMessage: String = "{sender} wrote:\n\n{body}",//FIXME bad 34 | val botSmsSendNoticeDelayedMessage: String = "A message will be sent for you at {sendAfter}.", 35 | val botSmsSendCreatedRoomAndSendMessage: String = "You were invited to a new created room and (if given) the message to the telephone number(s) {receiverNumbers} was (or will be) sent for you.", 36 | val botSmsSendCreatedRoomAndSendNoMessage: String = "You were invited to a new created room.", 37 | val botSmsSendSendMessage: String = "The message was (or will be) sent for you into an existing room with the telephone number(s) {receiverNumbers}.", 38 | val botSmsSendTooManyRooms: String = "No message was (or will be) sent, because there was more then one room with this telephone number(s) {receiverNumbers}. You can force room creation with the -c option.", 39 | val botSmsSendNoMessage: String = "There was no message content to send.", 40 | val botSmsSendDisabledRoomCreation: String = "No message was sent to telephone number(s) {receiverNumbers}, because either the bot wasn't invited to the room with the telephone number or creation was disabled by your command.", 41 | val botSmsSendSingleModeOnlyOneTelephoneNumberAllowed: String = "Single mode is allowed with one telephone number only.", 42 | val botSmsSendSingleModeDisabled: String = "Single mode was disabled by your admin.", 43 | val botSmsSendError: String = "There was an error while sending message to the telephone number(s) {receiverNumbers}. Reason: {error}", 44 | val botSmsInviteSuccess: String = "{sender} was invited to {roomAlias}.", 45 | val botSmsInviteError: String = "There was an error while invite {sender} to {roomAlias}. Reason: {error}", 46 | val botSmsAbortSuccess: String = "The deferred sending of messages in this room were aborted.", 47 | val botSmsAbortError: String = "There was an error running this command. Reason: {error}", 48 | val providerSendError: String = "Could not send sms to {receiver} with your provider. We will try to resend it and will notify you as soon as it was successful. Reason: {error}", 49 | val providerResendSuccess: String = "The resend was successful for all messages.", 50 | val providerReceiveError: String = "Could not receive messages through your configured provider. If this message does not appear again in the next few minutes, then retrying the receiving was successful. Reason: {error}" 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/appservice/SmsMatrixAppserviceRoomService.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.appservice 2 | 3 | import net.folivo.matrix.appservice.api.room.AppserviceRoomService.RoomExistingState 4 | import net.folivo.matrix.appservice.api.room.AppserviceRoomService.RoomExistingState.DOES_NOT_EXISTS 5 | import net.folivo.matrix.appservice.api.room.CreateRoomParameter 6 | import net.folivo.matrix.bot.appservice.DefaultAppserviceRoomService 7 | import net.folivo.matrix.bot.config.MatrixBotProperties 8 | import net.folivo.matrix.bot.room.MatrixRoomService 9 | import net.folivo.matrix.bot.util.BotServiceHelper 10 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 11 | import net.folivo.matrix.core.model.MatrixId.RoomAliasId 12 | import net.folivo.matrix.core.model.MatrixId.UserId 13 | import net.folivo.matrix.core.model.events.m.room.PowerLevelsEvent.PowerLevelsEventContent 14 | import net.folivo.matrix.restclient.api.rooms.Visibility.PRIVATE 15 | import org.springframework.stereotype.Service 16 | 17 | @Service 18 | class SmsMatrixAppserviceRoomService( 19 | roomService: MatrixRoomService, 20 | helper: BotServiceHelper, 21 | private val botProperties: MatrixBotProperties, 22 | private val bridgeProperties: SmsBridgeProperties 23 | ) : DefaultAppserviceRoomService(roomService, helper) { 24 | 25 | override suspend fun getCreateRoomParameter(roomAlias: RoomAliasId): CreateRoomParameter { 26 | val invitedUser = UserId(roomAlias.localpart, botProperties.serverName) 27 | return CreateRoomParameter( 28 | visibility = PRIVATE, 29 | powerLevelContentOverride = PowerLevelsEventContent( 30 | invite = 0, 31 | kick = 0, 32 | events = mapOf("m.room.name" to 0, "m.room.topic" to 0), 33 | users = mapOf(invitedUser to 100, botProperties.botUserId to 100) 34 | ), 35 | invite = setOf(invitedUser) 36 | ) 37 | } 38 | 39 | override suspend fun roomExistingState(roomAlias: RoomAliasId): RoomExistingState { 40 | return if (!bridgeProperties.singleModeEnabled) DOES_NOT_EXISTS 41 | else super.roomExistingState(roomAlias) 42 | } 43 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/appservice/SmsMatrixAppserviceUserService.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.appservice 2 | 3 | import net.folivo.matrix.appservice.api.user.RegisterUserParameter 4 | import net.folivo.matrix.bot.appservice.DefaultAppserviceUserService 5 | import net.folivo.matrix.bot.config.MatrixBotProperties 6 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 7 | import net.folivo.matrix.bot.user.MatrixUserService 8 | import net.folivo.matrix.bot.util.BotServiceHelper 9 | import net.folivo.matrix.core.model.MatrixId.UserId 10 | import org.springframework.stereotype.Service 11 | 12 | @Service 13 | class SmsMatrixAppserviceUserService( 14 | userService: MatrixUserService, 15 | helper: BotServiceHelper, 16 | private val smsBridgeProperties: SmsBridgeProperties, 17 | private val botProperties: MatrixBotProperties 18 | ) : DefaultAppserviceUserService(userService, helper, botProperties) { 19 | 20 | override suspend fun getRegisterUserParameter(userId: UserId): RegisterUserParameter { 21 | return if (userId == botProperties.botUserId) { 22 | RegisterUserParameter("SMS Bot") 23 | } else { 24 | val localPart = smsBridgeProperties.defaultLocalPart 25 | val telephoneNumber = userId.localpart.removePrefix(localPart) 26 | val displayName = "+$telephoneNumber (SMS)" 27 | RegisterUserParameter(displayName) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/appservice/SmsMatrixMembershipChangeService.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.appservice 2 | 3 | import net.folivo.matrix.bot.config.MatrixBotProperties 4 | import net.folivo.matrix.bot.membership.DefaultMembershipChangeService 5 | import net.folivo.matrix.bot.membership.MatrixMembershipService 6 | import net.folivo.matrix.bot.membership.MatrixMembershipSyncService 7 | import net.folivo.matrix.bot.room.MatrixRoomService 8 | import net.folivo.matrix.bot.user.MatrixUserService 9 | import net.folivo.matrix.core.model.MatrixId.RoomId 10 | import net.folivo.matrix.core.model.MatrixId.UserId 11 | import net.folivo.matrix.restclient.MatrixClient 12 | import org.springframework.stereotype.Service 13 | 14 | @Service 15 | class SmsMatrixMembershipChangeService( 16 | private val roomService: MatrixRoomService, 17 | membershipService: MatrixMembershipService, 18 | userService: MatrixUserService, 19 | membershipSyncService: MatrixMembershipSyncService, 20 | matrixClient: MatrixClient, 21 | private val botProperties: MatrixBotProperties 22 | ) : DefaultMembershipChangeService( 23 | roomService, 24 | membershipService, 25 | userService, 26 | membershipSyncService, 27 | matrixClient, 28 | botProperties 29 | ) { 30 | override suspend fun shouldJoinRoom(userId: UserId, roomId: RoomId): Boolean { 31 | if (userId == botProperties.botUserId) return super.shouldJoinRoom(userId, roomId) 32 | 33 | val roomAlias = roomService.getRoomAliasByRoomId(roomId) 34 | if (roomAlias != null && roomAlias.alias.localpart != userId.localpart) { 35 | return false 36 | } 37 | 38 | return super.shouldJoinRoom(userId, roomId) 39 | } 40 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/MessageToBotHandler.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.github.ajalt.clikt.core.* 4 | import kotlinx.coroutines.GlobalScope 5 | import kotlinx.coroutines.launch 6 | import net.folivo.matrix.bot.event.MessageContext 7 | import net.folivo.matrix.bot.membership.MatrixMembershipService 8 | import net.folivo.matrix.bot.user.MatrixUserService 9 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 10 | import net.folivo.matrix.core.model.MatrixId.RoomId 11 | import net.folivo.matrix.core.model.MatrixId.UserId 12 | import org.apache.tools.ant.types.Commandline 13 | import org.slf4j.LoggerFactory 14 | import org.springframework.stereotype.Component 15 | 16 | @Component 17 | class MessageToBotHandler( 18 | private val smsSendCommandHandler: SmsSendCommandHandler, 19 | private val smsInviteCommandHandler: SmsInviteCommandHandler, 20 | private val smsAbortCommandHandler: SmsAbortCommandHandler, 21 | private val phoneNumberService: PhoneNumberService, 22 | private val smsBridgeProperties: SmsBridgeProperties, 23 | private val userService: MatrixUserService, 24 | private val membershipService: MatrixMembershipService 25 | ) { 26 | companion object { 27 | private val LOG = LoggerFactory.getLogger(this::class.java) 28 | } 29 | 30 | suspend fun handleMessage( 31 | roomId: RoomId, 32 | body: String, 33 | senderId: UserId, 34 | context: MessageContext 35 | ): Boolean { 36 | val sender = userService.getOrCreateUser(senderId) 37 | val membershipSize = membershipService.getMembershipsSizeByRoomId(roomId) 38 | return if (sender.isManaged) { 39 | LOG.debug("ignore message from managed user") 40 | false 41 | } else if (body.startsWith("sms")) { 42 | // TODO is there a less hacky way for "sms abort"? Maybe completely switch to non-console? 43 | if (membershipSize > 2 && !body.startsWith("sms abort")) { 44 | LOG.debug("to many members in room for sms command") 45 | context.answer(smsBridgeProperties.templates.botTooManyMembers) 46 | true 47 | } else { 48 | LOG.debug("run sms command $body") 49 | 50 | //TODO test 51 | GlobalScope.launch { 52 | val answerConsole = SmsBotConsole(context) 53 | try { 54 | val args = Commandline.translateCommandline(body.removePrefix("sms")) 55 | 56 | SmsCommand().context { console = answerConsole } 57 | .subcommands( 58 | SmsSendCommand( 59 | senderId, 60 | smsSendCommandHandler, 61 | phoneNumberService, 62 | smsBridgeProperties 63 | ), 64 | SmsInviteCommand( 65 | senderId, 66 | smsInviteCommandHandler 67 | ), 68 | SmsAbortCommand( 69 | roomId, 70 | smsAbortCommandHandler 71 | ) 72 | ) 73 | .parse(args) 74 | } catch (e: PrintHelpMessage) { 75 | answerConsole.print(e.command.getFormattedHelp(), false) 76 | } catch (e: PrintCompletionMessage) { 77 | e.message?.also { answerConsole.print(it, false) } 78 | } catch (e: PrintMessage) { 79 | e.message?.also { answerConsole.print(it, false) } 80 | } catch (e: UsageError) { 81 | answerConsole.print(e.helpMessage(), true) 82 | } catch (e: CliktError) { 83 | e.message?.also { answerConsole.print(it, true) } 84 | } catch (e: Abort) { 85 | answerConsole.print("Aborted!", true) 86 | } catch (error: Throwable) { 87 | context.answer( 88 | smsBridgeProperties.templates.botSmsError 89 | .replace("{error}", error.message ?: "unknown") 90 | ) 91 | } 92 | }.join() 93 | true 94 | } 95 | } else if (membershipSize == 2L) { 96 | LOG.debug("it seems to be a bot room, but message didn't start with 'sms'") 97 | context.answer(smsBridgeProperties.templates.botHelp) 98 | true 99 | } else false 100 | } 101 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/MessageToSmsHandler.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import kotlinx.coroutines.flow.* 4 | import net.folivo.matrix.bot.config.MatrixBotProperties 5 | import net.folivo.matrix.bot.event.MessageContext 6 | import net.folivo.matrix.bot.room.MatrixRoomService 7 | import net.folivo.matrix.bot.user.MatrixUserService 8 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 9 | import net.folivo.matrix.bridge.sms.mapping.MatrixSmsMappingService 10 | import net.folivo.matrix.bridge.sms.provider.SmsProvider 11 | import net.folivo.matrix.core.model.MatrixId.RoomId 12 | import net.folivo.matrix.core.model.MatrixId.UserId 13 | import org.slf4j.LoggerFactory 14 | import org.springframework.stereotype.Service 15 | 16 | @Service 17 | class MessageToSmsHandler( 18 | private val botProperties: MatrixBotProperties, 19 | private val smsBridgeProperties: SmsBridgeProperties, 20 | private val smsProvider: SmsProvider, 21 | private val roomService: MatrixRoomService, 22 | private val userService: MatrixUserService, 23 | private val mappingService: MatrixSmsMappingService 24 | ) { 25 | 26 | private val templates = smsBridgeProperties.templates 27 | 28 | companion object { 29 | private val LOG = LoggerFactory.getLogger(this::class.java) 30 | } 31 | 32 | suspend fun handleMessage( 33 | roomId: RoomId, 34 | body: String, 35 | senderId: UserId, 36 | context: MessageContext, 37 | isTextMessage: Boolean 38 | ) { 39 | userService.getUsersByRoom(roomId) 40 | .filter { it.isManaged && it.id != senderId && it.id != botProperties.botUserId } 41 | .map { "+" + it.id.localpart.removePrefix(smsBridgeProperties.defaultLocalPart) to it.id } 42 | .collect { (receiverNumber, receiverId) -> 43 | if (isTextMessage) { 44 | LOG.debug("send SMS from $roomId to $receiverNumber") 45 | val needsToken = !smsBridgeProperties.allowMappingWithoutToken 46 | || !roomService.getOrCreateRoom(roomId).isManaged 47 | && roomService.getRoomsByMembers(setOf(receiverId)).take(2).count() > 1 48 | val mappingToken = if (needsToken) 49 | mappingService.getOrCreateMapping(receiverId, roomId).mappingToken else null 50 | 51 | try { 52 | insertBodyAndSend( 53 | sender = senderId, 54 | receiverNumber = receiverNumber, 55 | body = body, 56 | mappingToken = mappingToken 57 | ) 58 | } catch (error: Throwable) { 59 | LOG.error( 60 | "Could not send sms from room $roomId and $senderId. " + 61 | "This should be fixed.", error 62 | ) 63 | context.answer( 64 | templates.sendSmsError.replace("{error}", error.message ?: "unknown"), 65 | asUserId = receiverId 66 | ) 67 | } 68 | } else { 69 | LOG.debug("cannot send SMS from $roomId to $receiverNumber because of incompatible message type") 70 | context.answer(templates.sendSmsIncompatibleMessage, asUserId = receiverId) 71 | } 72 | } 73 | } 74 | 75 | private suspend fun insertBodyAndSend( 76 | sender: UserId, 77 | receiverNumber: String, 78 | body: String, 79 | mappingToken: Int? 80 | ) { 81 | val messageTemplate = 82 | if (sender == botProperties.botUserId) 83 | templates.outgoingMessageFromBot 84 | else templates.outgoingMessage 85 | val completeTemplate = 86 | if (mappingToken == null) messageTemplate 87 | else messageTemplate + templates.outgoingMessageToken.replace("{token}", "#$mappingToken") 88 | 89 | val templateBody = completeTemplate 90 | .replace("{sender}", sender.full) 91 | .replace("{body}", body) 92 | 93 | smsProvider.sendSms(receiver = receiverNumber, body = templateBody) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/PhoneNumberService.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.google.i18n.phonenumbers.NumberParseException 4 | import com.google.i18n.phonenumbers.NumberParseException.ErrorType.NOT_A_NUMBER 5 | import com.google.i18n.phonenumbers.PhoneNumberUtil 6 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 7 | import org.springframework.stereotype.Service 8 | 9 | @Service 10 | class PhoneNumberService(private val smsBridgeProperties: SmsBridgeProperties) { 11 | 12 | private val phoneNumberUtil: PhoneNumberUtil = PhoneNumberUtil.getInstance() 13 | 14 | private val alphanumericRegex = "^(?=.*[a-zA-Z])(?=.*[a-zA-Z0-9])([a-zA-Z0-9 ]{1,11})\$".toRegex() 15 | 16 | fun parseToInternationalNumber(raw: String): String { 17 | return phoneNumberUtil.parse(raw, smsBridgeProperties.defaultRegion) 18 | .let { 19 | if (!phoneNumberUtil.isValidNumber(it)) throw NumberParseException( 20 | NOT_A_NUMBER, 21 | "not a valid number" 22 | ) 23 | "+${it.countryCode}${it.nationalNumber}" 24 | } 25 | } 26 | 27 | fun isAlphanumeric(raw: String): Boolean { 28 | return alphanumericRegex.matches(raw) 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/ReceiveSmsService.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import net.folivo.matrix.bot.config.MatrixBotProperties 4 | import net.folivo.matrix.bot.membership.MatrixMembershipService 5 | import net.folivo.matrix.bot.room.MatrixRoomService 6 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 7 | import net.folivo.matrix.bridge.sms.mapping.MatrixSmsMappingService 8 | import net.folivo.matrix.bridge.sms.message.MatrixMessage 9 | import net.folivo.matrix.bridge.sms.message.MatrixMessageService 10 | import net.folivo.matrix.core.model.MatrixId.RoomAliasId 11 | import net.folivo.matrix.core.model.MatrixId.UserId 12 | import net.folivo.matrix.core.model.events.m.room.message.TextMessageEventContent 13 | import net.folivo.matrix.restclient.MatrixClient 14 | import org.slf4j.LoggerFactory 15 | import org.springframework.stereotype.Service 16 | 17 | @Service 18 | class ReceiveSmsService( 19 | private val matrixClient: MatrixClient, 20 | private val mappingService: MatrixSmsMappingService, 21 | private val messageService: MatrixMessageService, 22 | private val membershipService: MatrixMembershipService, 23 | private val roomService: MatrixRoomService, 24 | private val phoneNumberService: PhoneNumberService, 25 | private val matrixBotProperties: MatrixBotProperties, 26 | private val smsBridgeProperties: SmsBridgeProperties 27 | ) { 28 | 29 | private val templates = smsBridgeProperties.templates 30 | 31 | companion object { 32 | private val LOG = LoggerFactory.getLogger(this::class.java) 33 | } 34 | 35 | suspend fun receiveSms(body: String, providerSender: String): String? { 36 | if (phoneNumberService.isAlphanumeric(providerSender)) { 37 | sendMessageFromInvalidNumberToDefaultRoom(body, providerSender) 38 | return null 39 | } else { 40 | val sender: String 41 | try { 42 | sender = phoneNumberService.parseToInternationalNumber(providerSender) 43 | } catch (error: Throwable) { 44 | LOG.debug("could not parse to international number", error) 45 | sendMessageFromInvalidNumberToDefaultRoom(body, providerSender) 46 | return null 47 | } 48 | 49 | val userIdLocalpart = "${smsBridgeProperties.defaultLocalPart}${sender.removePrefix("+")}" 50 | val userId = UserId(userIdLocalpart, matrixBotProperties.serverName) 51 | 52 | val mappingTokenMatch = Regex("#[0-9]{1,9}").find(body) 53 | val mappingToken = mappingTokenMatch?.value?.substringAfter('#')?.toInt() 54 | 55 | val cleanedBody = mappingTokenMatch?.let { body.removeRange(it.range) }?.trim() ?: body 56 | 57 | val roomIdFromMappingToken = mappingService.getRoomId( 58 | userId = userId, 59 | mappingToken = mappingToken 60 | ) 61 | if (roomIdFromMappingToken != null) { 62 | LOG.debug("receive SMS from $sender to $roomIdFromMappingToken") 63 | matrixClient.roomsApi.sendRoomEvent( 64 | roomIdFromMappingToken, 65 | TextMessageEventContent(cleanedBody), 66 | asUserId = userId 67 | ) 68 | return null 69 | } else if (smsBridgeProperties.singleModeEnabled) { 70 | LOG.debug("receive SMS without or wrong mappingToken from $sender to single room") 71 | val roomAliasId = RoomAliasId(userIdLocalpart, matrixBotProperties.serverName) 72 | val roomIdFromAlias = roomService.getRoomAlias(roomAliasId)?.roomId 73 | ?: matrixClient.roomsApi.getRoomAlias(roomAliasId).roomId 74 | 75 | messageService.sendRoomMessage( 76 | MatrixMessage(roomIdFromAlias, cleanedBody, isNotice = false, asUserId = userId) 77 | ) 78 | 79 | if (membershipService.hasRoomOnlyManagedUsersLeft(roomIdFromAlias)) { 80 | if (smsBridgeProperties.defaultRoomId != null) { 81 | val message = templates.defaultRoomIncomingMessageWithSingleMode 82 | .replace("{sender}", sender) 83 | .replace("{roomAlias}", roomAliasId.toString()) 84 | matrixClient.roomsApi.sendRoomEvent( 85 | smsBridgeProperties.defaultRoomId, 86 | TextMessageEventContent(message) 87 | ) 88 | } else return templates.answerInvalidTokenWithoutDefaultRoom.takeIf { !it.isNullOrEmpty() } 89 | } 90 | return null 91 | } else { 92 | LOG.debug("receive SMS without or wrong mappingToken from $sender to default room ${smsBridgeProperties.defaultRoomId}") 93 | 94 | return if (smsBridgeProperties.defaultRoomId != null) { 95 | val message = templates.defaultRoomIncomingMessage 96 | .replace("{sender}", sender) 97 | .replace("{body}", cleanedBody) 98 | 99 | matrixClient.roomsApi.sendRoomEvent( 100 | smsBridgeProperties.defaultRoomId, 101 | TextMessageEventContent(message) 102 | ) 103 | templates.answerInvalidTokenWithDefaultRoom.takeIf { !it.isNullOrEmpty() } 104 | } else templates.answerInvalidTokenWithoutDefaultRoom.takeIf { !it.isNullOrEmpty() } 105 | } 106 | } 107 | } 108 | 109 | private suspend fun sendMessageFromInvalidNumberToDefaultRoom(body: String, providerSender: String) { 110 | if (smsBridgeProperties.defaultRoomId != null) { 111 | LOG.debug("receive SMS with invalid or alphanumeric number from sender $providerSender to default room ${smsBridgeProperties.defaultRoomId}") 112 | val message = templates.defaultRoomIncomingMessage 113 | .replace("{sender}", providerSender) 114 | .replace("{body}", body) 115 | 116 | matrixClient.roomsApi.sendRoomEvent( 117 | smsBridgeProperties.defaultRoomId, 118 | TextMessageEventContent(message) 119 | ) 120 | } else { 121 | LOG.warn("you got a message from an alphanumeric or invalid sender. You should enable default room to receive this message regular: $providerSender sent: $body") 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/SmsAbortCommand.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | import kotlinx.coroutines.runBlocking 5 | import net.folivo.matrix.core.model.MatrixId.RoomId 6 | 7 | class SmsAbortCommand( 8 | private val roomId: RoomId, 9 | private val handler: SmsAbortCommandHandler 10 | ) : CliktCommand(name = "abort") { 11 | 12 | override fun run() { 13 | echo(runBlocking { handler.handleCommand(roomId) }) 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/SmsAbortCommandHandler.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 4 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties.SmsBridgeTemplateProperties 5 | import net.folivo.matrix.bridge.sms.message.MatrixMessageService 6 | import net.folivo.matrix.core.model.MatrixId.RoomId 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.stereotype.Component 9 | 10 | 11 | @Component 12 | class SmsAbortCommandHandler( 13 | private val messageService: MatrixMessageService, 14 | smsBridgeProperties: SmsBridgeProperties, 15 | ) { 16 | 17 | private val templates: SmsBridgeTemplateProperties = smsBridgeProperties.templates 18 | 19 | companion object { 20 | private val LOG = LoggerFactory.getLogger(this::class.java) 21 | } 22 | 23 | suspend fun handleCommand( 24 | roomId: RoomId 25 | ): String { 26 | return try { 27 | messageService.deleteByRoomId(roomId) 28 | templates.botSmsAbortSuccess 29 | } catch (ex: Throwable) { 30 | LOG.debug("got exception") 31 | templates.botSmsAbortError 32 | .replace("{error}", ex.message ?: "unknown") 33 | } 34 | } 35 | 36 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/SmsBotConsole.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.github.ajalt.clikt.output.CliktConsole 4 | import kotlinx.coroutines.runBlocking 5 | import net.folivo.matrix.bot.event.MessageContext 6 | import org.slf4j.LoggerFactory 7 | 8 | class SmsBotConsole( 9 | private val messageContext: MessageContext 10 | ) : CliktConsole { 11 | override val lineSeparator: String = "" 12 | 13 | companion object { 14 | private val LOG = LoggerFactory.getLogger(this::class.java) 15 | } 16 | 17 | override fun print(text: String, error: Boolean) { 18 | LOG.debug("try to send answer of command: $text") 19 | runBlocking { 20 | messageContext.answer(text) 21 | } 22 | } 23 | 24 | override fun promptForLine(prompt: String, hideInput: Boolean): String? { 25 | return null 26 | } 27 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/SmsCommand.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | 5 | 6 | class SmsCommand : CliktCommand(name = "sms") { 7 | override fun run() { 8 | } 9 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/SmsInviteCommand.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | import com.github.ajalt.clikt.parameters.arguments.argument 5 | import com.github.ajalt.clikt.parameters.arguments.convert 6 | import kotlinx.coroutines.runBlocking 7 | import net.folivo.matrix.core.model.MatrixId.RoomAliasId 8 | import net.folivo.matrix.core.model.MatrixId.UserId 9 | 10 | class SmsInviteCommand( 11 | private val sender: UserId, 12 | private val handler: SmsInviteCommandHandler 13 | ) : CliktCommand(name = "invite") { 14 | 15 | private val alias by argument("alias").convert { RoomAliasId(it) } 16 | 17 | override fun run() { 18 | echo(runBlocking { handler.handleCommand(sender, alias) }) 19 | } 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/SmsInviteCommandHandler.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import net.folivo.matrix.bot.room.MatrixRoomService 4 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 5 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties.SmsBridgeTemplateProperties 6 | import net.folivo.matrix.core.model.MatrixId.RoomAliasId 7 | import net.folivo.matrix.core.model.MatrixId.UserId 8 | import net.folivo.matrix.restclient.MatrixClient 9 | import org.slf4j.LoggerFactory 10 | import org.springframework.stereotype.Component 11 | 12 | 13 | @Component 14 | class SmsInviteCommandHandler( 15 | private val roomService: MatrixRoomService, 16 | private val matrixClient: MatrixClient, 17 | smsBridgeProperties: SmsBridgeProperties, 18 | ) { 19 | 20 | private val templates: SmsBridgeTemplateProperties = smsBridgeProperties.templates 21 | 22 | companion object { 23 | private val LOG = LoggerFactory.getLogger(this::class.java) 24 | } 25 | 26 | suspend fun handleCommand( 27 | sender: UserId, 28 | alias: RoomAliasId 29 | ): String { 30 | return try { 31 | val roomId = roomService.getRoomAlias(alias)?.roomId 32 | ?: matrixClient.roomsApi.getRoomAlias(alias).roomId 33 | matrixClient.roomsApi.inviteUser(roomId, sender) 34 | templates.botSmsInviteSuccess 35 | .replace("{roomAlias}", alias.full) 36 | .replace("{sender}", sender.full) 37 | } catch (ex: Throwable) { 38 | LOG.debug("got exception") 39 | templates.botSmsInviteError 40 | .replace("{roomAlias}", alias.full) 41 | .replace("{sender}", sender.full) 42 | .replace("{error}", ex.message ?: "unknown") 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/SmsMessageHandler.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import net.folivo.matrix.bot.config.MatrixBotProperties 4 | import net.folivo.matrix.bot.event.MatrixMessageHandler 5 | import net.folivo.matrix.bot.event.MessageContext 6 | import net.folivo.matrix.bot.membership.MatrixMembershipService 7 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 8 | import net.folivo.matrix.core.model.events.m.room.message.MessageEvent.MessageEventContent 9 | import net.folivo.matrix.core.model.events.m.room.message.NoticeMessageEventContent 10 | import net.folivo.matrix.core.model.events.m.room.message.TextMessageEventContent 11 | import org.slf4j.LoggerFactory 12 | import org.springframework.stereotype.Component 13 | 14 | @Component 15 | class SmsMessageHandler( 16 | private val messageToSmsHandler: MessageToSmsHandler, 17 | private val messageToBotHandler: MessageToBotHandler, 18 | private val membershipService: MatrixMembershipService, 19 | private val botProperties: MatrixBotProperties, 20 | private val smsBridgeProperties: SmsBridgeProperties 21 | ) : MatrixMessageHandler { 22 | 23 | companion object { 24 | private val LOG = LoggerFactory.getLogger(this::class.java) 25 | } 26 | 27 | override suspend fun handleMessage(content: MessageEventContent, context: MessageContext) { 28 | val roomId = context.roomId 29 | val senderId = context.originalEvent.sender 30 | LOG.debug("handle message in room $roomId from sender $senderId") 31 | 32 | if (context.roomId == smsBridgeProperties.defaultRoomId || content is NoticeMessageEventContent) { 33 | LOG.debug("ignored notice message or message to default room") 34 | return 35 | } else { 36 | val didHandleMessage = 37 | membershipService.doesRoomContainsMembers(roomId, setOf(botProperties.botUserId)) 38 | && messageToBotHandler.handleMessage( 39 | roomId = roomId, 40 | body = content.body, 41 | senderId = senderId, 42 | context = context 43 | ) 44 | 45 | if (didHandleMessage) { 46 | LOG.debug("ignored message because it was for bot or only a notice message") 47 | return 48 | } else { 49 | messageToSmsHandler.handleMessage( 50 | roomId = roomId, 51 | body = content.body, 52 | senderId = senderId, 53 | context = context, 54 | isTextMessage = content is TextMessageEventContent 55 | ) 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/SmsSendCommand.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.github.ajalt.clikt.core.CliktCommand 4 | import com.github.ajalt.clikt.parameters.arguments.argument 5 | import com.github.ajalt.clikt.parameters.arguments.optional 6 | import com.github.ajalt.clikt.parameters.options.* 7 | import com.github.ajalt.clikt.parameters.types.enum 8 | import com.google.i18n.phonenumbers.NumberParseException 9 | import kotlinx.coroutines.runBlocking 10 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 11 | import net.folivo.matrix.bridge.sms.handler.SmsSendCommand.RoomCreationMode.AUTO 12 | import net.folivo.matrix.core.model.MatrixId.UserId 13 | import org.slf4j.LoggerFactory 14 | import java.time.LocalDateTime 15 | 16 | class SmsSendCommand( 17 | private val sender: UserId, 18 | private val handler: SmsSendCommandHandler, 19 | private val phoneNumberService: PhoneNumberService, 20 | private val smsBridgeProperties: SmsBridgeProperties 21 | ) : CliktCommand(name = "send") { 22 | 23 | companion object { 24 | private val LOG = LoggerFactory.getLogger(this::class.java) 25 | } 26 | 27 | private val body by argument("body").optional() 28 | 29 | private val telephoneNumbers by option("-t", "--telephoneNumber").multiple(required = true).unique() 30 | private val roomName by option("-n", "--newRoomName") 31 | private val roomCreationMode by option("-m", "--roomCreationMode").enum().default(AUTO) 32 | private val useGroup by option("-g", "--group").flag() 33 | private val sendAfter by option("-a", "--sendAfter").convert { LocalDateTime.parse(it) } 34 | private val inviteUserIds by option("-i", "--invite").convert { UserId(it) }.multiple().unique() 35 | 36 | enum class RoomCreationMode { 37 | AUTO, ALWAYS, NO, SINGLE 38 | } 39 | 40 | override fun run() { 41 | try { 42 | val receiverNumbers = telephoneNumbers.map { phoneNumberService.parseToInternationalNumber(it) } 43 | if (useGroup) { 44 | LOG.debug("use group and send message") 45 | echo(runBlocking { 46 | handler.handleCommand( 47 | body = body, 48 | senderId = sender, 49 | receiverNumbers = receiverNumbers.toSet(), 50 | inviteUserIds = inviteUserIds, 51 | roomName = roomName, 52 | roomCreationMode = roomCreationMode, 53 | sendAfterLocal = sendAfter 54 | ) 55 | }) 56 | } else { 57 | LOG.debug("use one room for each number and send message") 58 | receiverNumbers.forEach { number -> 59 | echo( 60 | runBlocking { 61 | handler.handleCommand( 62 | body = body, 63 | senderId = sender, 64 | receiverNumbers = setOf(number), 65 | inviteUserIds = inviteUserIds, 66 | roomName = roomName, 67 | sendAfterLocal = sendAfter, 68 | roomCreationMode = roomCreationMode 69 | ) 70 | }) 71 | } 72 | } 73 | } catch (ex: NumberParseException) { 74 | LOG.debug("got NumberParseException") 75 | echo(smsBridgeProperties.templates.botSmsSendInvalidTelephoneNumber) 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/handler/SmsSendCommandHandler.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import kotlinx.coroutines.flow.map 4 | import kotlinx.coroutines.flow.take 5 | import kotlinx.coroutines.flow.toSet 6 | import net.folivo.matrix.appservice.api.AppserviceHandlerHelper 7 | import net.folivo.matrix.bot.config.MatrixBotProperties 8 | import net.folivo.matrix.bot.membership.MatrixMembershipService 9 | import net.folivo.matrix.bot.room.MatrixRoomService 10 | import net.folivo.matrix.bot.user.MatrixUserService 11 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 12 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties.SmsBridgeTemplateProperties 13 | import net.folivo.matrix.bridge.sms.handler.SmsSendCommand.RoomCreationMode 14 | import net.folivo.matrix.bridge.sms.handler.SmsSendCommand.RoomCreationMode.* 15 | import net.folivo.matrix.bridge.sms.message.MatrixMessage 16 | import net.folivo.matrix.bridge.sms.message.MatrixMessageService 17 | import net.folivo.matrix.core.api.MatrixServerException 18 | import net.folivo.matrix.core.model.MatrixId.* 19 | import net.folivo.matrix.core.model.events.m.room.NameEvent.NameEventContent 20 | import net.folivo.matrix.core.model.events.m.room.PowerLevelsEvent.PowerLevelsEventContent 21 | import net.folivo.matrix.restclient.MatrixClient 22 | import net.folivo.matrix.restclient.api.rooms.Visibility.PRIVATE 23 | import org.slf4j.LoggerFactory 24 | import org.springframework.stereotype.Component 25 | import java.time.Instant 26 | import java.time.LocalDateTime 27 | import java.time.ZoneId 28 | import java.time.temporal.ChronoUnit 29 | 30 | 31 | @Component 32 | class SmsSendCommandHandler( 33 | private val userService: MatrixUserService, 34 | private val roomService: MatrixRoomService, 35 | private val membershipService: MatrixMembershipService, 36 | private val messageService: MatrixMessageService, 37 | private val appserviceHandlerHelper: AppserviceHandlerHelper, 38 | private val matrixClient: MatrixClient, 39 | private val botProperties: MatrixBotProperties, 40 | private val smsBridgeProperties: SmsBridgeProperties, 41 | ) { 42 | 43 | private val templates: SmsBridgeTemplateProperties = smsBridgeProperties.templates 44 | 45 | companion object { 46 | private val LOG = LoggerFactory.getLogger(this::class.java) 47 | } 48 | 49 | suspend fun handleCommand( 50 | body: String?, 51 | senderId: UserId, 52 | receiverNumbers: Set, 53 | inviteUserIds: Set, 54 | roomName: String?, 55 | sendAfterLocal: LocalDateTime?, 56 | roomCreationMode: RoomCreationMode 57 | ): String { 58 | LOG.debug("handle command") 59 | val requiredManagedReceiverIds = receiverNumbers.map { 60 | UserId("${smsBridgeProperties.defaultLocalPart}${it.removePrefix("+")}", botProperties.serverName) 61 | }.toSet() 62 | val membersWithoutBot = setOf(senderId, *requiredManagedReceiverIds.toTypedArray()) 63 | val rooms = roomService.getRoomsByMembers(membersWithoutBot) 64 | .take(2) 65 | .map { it.id } 66 | .toSet() 67 | 68 | try { 69 | val answer = when (roomCreationMode) { 70 | AUTO -> { 71 | if (smsBridgeProperties.singleModeEnabled && receiverNumbers.size == 1) { 72 | sendMessageToRoomAlias( 73 | senderId, 74 | body, 75 | roomName, 76 | requiredManagedReceiverIds.first(), 77 | inviteUserIds, 78 | sendAfterLocal 79 | ) 80 | } else when (rooms.size) { 81 | 0 -> createRoomAndSendMessage( 82 | body, 83 | senderId, 84 | roomName, 85 | requiredManagedReceiverIds, 86 | inviteUserIds, 87 | sendAfterLocal 88 | ) 89 | 1 -> sendMessageToRoom( 90 | rooms.first(), 91 | senderId, 92 | body, 93 | requiredManagedReceiverIds, 94 | sendAfterLocal 95 | ) 96 | else -> templates.botSmsSendTooManyRooms 97 | } 98 | } 99 | ALWAYS -> { 100 | createRoomAndSendMessage( 101 | body, 102 | senderId, 103 | roomName, 104 | requiredManagedReceiverIds, 105 | inviteUserIds, 106 | sendAfterLocal 107 | ) 108 | } 109 | SINGLE -> { 110 | if (!smsBridgeProperties.singleModeEnabled) { 111 | templates.botSmsSendSingleModeDisabled 112 | } else if (receiverNumbers.size == 1) { 113 | sendMessageToRoomAlias( 114 | senderId, 115 | body, 116 | roomName, 117 | requiredManagedReceiverIds.first(), 118 | inviteUserIds, 119 | sendAfterLocal 120 | ) 121 | } else { 122 | templates.botSmsSendSingleModeOnlyOneTelephoneNumberAllowed 123 | } 124 | } 125 | NO -> { 126 | when (rooms.size) { 127 | 0 -> templates.botSmsSendDisabledRoomCreation 128 | 1 -> sendMessageToRoom( 129 | rooms.first(), 130 | senderId, 131 | body, 132 | requiredManagedReceiverIds, 133 | sendAfterLocal 134 | ) 135 | else -> templates.botSmsSendTooManyRooms 136 | } 137 | } 138 | } 139 | 140 | return answer.replace("{receiverNumbers}", receiverNumbers.joinToString()) 141 | } catch (error: Throwable) { 142 | LOG.warn("trying to create room, join room or send message failed: ${error.message}") 143 | return templates.botSmsSendError 144 | .replace("{error}", error.message ?: "unknown") 145 | .replace("{receiverNumbers}", receiverNumbers.joinToString()) 146 | } 147 | } 148 | 149 | internal suspend fun sendMessageToRoomAlias( 150 | senderId: UserId, 151 | body: String?, 152 | roomName: String?, 153 | requiredManagedReceiverId: UserId, 154 | inviteUserIds: Set, 155 | sendAfterLocal: LocalDateTime? 156 | ): String { 157 | LOG.debug("send message to room alias") 158 | val aliasLocalpart = requiredManagedReceiverId.localpart 159 | val roomAliasId = RoomAliasId(aliasLocalpart, botProperties.serverName) 160 | val existingRoomId = roomService.getRoomAlias(roomAliasId)?.roomId 161 | val roomId = existingRoomId 162 | ?: matrixClient.roomsApi.getRoomAlias(roomAliasId).roomId 163 | 164 | if (roomName != null && (existingRoomId == null || tryGetRoomName(roomId).isNullOrEmpty())) { 165 | matrixClient.roomsApi.sendStateEvent(roomId, NameEventContent(roomName)) 166 | } 167 | setOf(senderId, *inviteUserIds.toTypedArray()) 168 | .filter { !membershipService.doesRoomContainsMembers(roomId, setOf(it)) } 169 | .forEach { matrixClient.roomsApi.inviteUser(roomId, it) } 170 | 171 | return sendMessageToRoom( 172 | roomId, 173 | senderId, 174 | body, 175 | setOf(requiredManagedReceiverId), 176 | sendAfterLocal, 177 | denyBotInvite = true 178 | ) 179 | } 180 | 181 | private suspend inline fun tryGetRoomName(roomId: RoomId): String? { 182 | return try { 183 | matrixClient.roomsApi.getStateEvent(roomId).name 184 | } catch (error: MatrixServerException) { 185 | null 186 | } 187 | } 188 | 189 | internal suspend fun createRoomAndSendMessage( 190 | body: String?, 191 | senderId: UserId, 192 | roomName: String?, 193 | requiredManagedReceiverIds: Set, 194 | inviteUserIds: Set, 195 | sendAfterLocal: LocalDateTime? 196 | ): String { 197 | LOG.debug("ensure that users has already been created") 198 | requiredManagedReceiverIds.forEach { 199 | if (!userService.existsUser(it)) { 200 | appserviceHandlerHelper.registerManagedUser(it) 201 | } 202 | } 203 | 204 | LOG.debug("create room and send message") 205 | val roomId = matrixClient.roomsApi.createRoom( 206 | name = roomName, 207 | invite = setOf(senderId, *requiredManagedReceiverIds.toTypedArray(), *inviteUserIds.toTypedArray()), 208 | visibility = PRIVATE, 209 | powerLevelContentOverride = PowerLevelsEventContent( 210 | invite = 0, 211 | kick = 0, 212 | events = mapOf("m.room.name" to 0, "m.room.topic" to 0), 213 | users = mapOf( 214 | botProperties.botUserId to 100, 215 | *requiredManagedReceiverIds.map { it to 100 }.toTypedArray() 216 | ) 217 | ) 218 | ) 219 | roomService.getOrCreateRoom(roomId) 220 | 221 | return if (body.isNullOrEmpty()) { 222 | templates.botSmsSendCreatedRoomAndSendNoMessage 223 | } else { 224 | sendMessageToRoom(roomId, senderId, body, requiredManagedReceiverIds, sendAfterLocal, true) 225 | templates.botSmsSendCreatedRoomAndSendMessage 226 | } 227 | } 228 | 229 | internal suspend fun sendMessageToRoom( 230 | roomId: RoomId, 231 | senderId: UserId, 232 | body: String?, 233 | requiredManagedReceiverIds: Set, 234 | sendAfterLocal: LocalDateTime?, 235 | denyBotInvite: Boolean = false 236 | ): String { 237 | if (body.isNullOrBlank()) { 238 | return templates.botSmsSendNoMessage 239 | } else { 240 | val botIsMember = denyBotInvite || membershipService.doesRoomContainsMembers( 241 | roomId, 242 | setOf(botProperties.botUserId) 243 | ) 244 | if (!botIsMember) { 245 | LOG.debug("try to invite sms bot user to room $roomId") 246 | matrixClient.roomsApi.inviteUser( 247 | roomId = roomId, 248 | userId = botProperties.botUserId, 249 | asUserId = requiredManagedReceiverIds.first() 250 | ) 251 | } 252 | 253 | val sendAfter = sendAfterLocal?.atZone(ZoneId.of(smsBridgeProperties.defaultTimeZone))?.toInstant() 254 | 255 | if (sendAfter != null && Instant.now().until(sendAfter, ChronoUnit.SECONDS) > 15) { 256 | LOG.debug("notify room $roomId that message will be send later") 257 | messageService.sendRoomMessage( 258 | MatrixMessage( 259 | roomId = roomId, 260 | body = templates.botSmsSendNoticeDelayedMessage 261 | .replace("{sendAfter}", sendAfterLocal.toString()), 262 | isNotice = true 263 | ), requiredManagedReceiverIds.toSet() 264 | ) 265 | } 266 | LOG.debug("send message to room $roomId") 267 | messageService.sendRoomMessage( 268 | MatrixMessage( 269 | roomId = roomId, 270 | body = templates.botSmsSendNewRoomMessage 271 | .replace("{sender}", senderId.full) 272 | .replace("{body}", body), 273 | sendAfter = sendAfter ?: Instant.now() 274 | ), requiredManagedReceiverIds.toSet() 275 | ) 276 | 277 | return templates.botSmsSendSendMessage 278 | } 279 | } 280 | 281 | } 282 | -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/mapping/MatrixSmsMapping.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.mapping 2 | 3 | import org.springframework.data.annotation.Id 4 | import org.springframework.data.annotation.Version 5 | import org.springframework.data.relational.core.mapping.Column 6 | import org.springframework.data.relational.core.mapping.Table 7 | 8 | 9 | @Table("matrix_sms_mapping") 10 | data class MatrixSmsMapping( 11 | @Id 12 | @Column("membership_id") 13 | val membershipId: String, 14 | @Column("mapping_token") 15 | val mappingToken: Int, 16 | @Version 17 | @Column("version") 18 | val version: Int = 0 19 | ) -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/mapping/MatrixSmsMappingRepository.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.mapping 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import net.folivo.matrix.core.model.MatrixId.UserId 5 | import org.springframework.data.r2dbc.repository.Query 6 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 7 | import org.springframework.stereotype.Repository 8 | 9 | @Repository 10 | interface MatrixSmsMappingRepository : CoroutineCrudRepository { 11 | 12 | @Query( 13 | """ 14 | SELECT * from matrix_sms_mapping map 15 | JOIN matrix_membership mem ON mem.id = map.membership_id 16 | WHERE mem.user_id = :userId 17 | ORDER BY map.mapping_token DESC 18 | """ 19 | ) 20 | fun findByUserIdSortByMappingTokenDesc(userId: UserId): Flow 21 | 22 | @Query( 23 | """ 24 | SELECT * from matrix_sms_mapping map 25 | JOIN matrix_membership mem ON mem.id = map.membership_id 26 | WHERE map.mapping_token = :mappingToken AND mem.user_id = :userId 27 | """ 28 | ) 29 | suspend fun findByUserIdAndMappingToken(userId: UserId, mappingToken: Int): MatrixSmsMapping? 30 | 31 | suspend fun findByMembershipId(membershipId: String): MatrixSmsMapping? 32 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/mapping/MatrixSmsMappingService.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.mapping 2 | 3 | import kotlinx.coroutines.flow.firstOrNull 4 | import kotlinx.coroutines.flow.take 5 | import kotlinx.coroutines.flow.toList 6 | import net.folivo.matrix.bot.membership.MatrixMembershipService 7 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 8 | import net.folivo.matrix.core.model.MatrixId.RoomId 9 | import net.folivo.matrix.core.model.MatrixId.UserId 10 | import org.springframework.stereotype.Service 11 | 12 | @Service 13 | class MatrixSmsMappingService( 14 | private val mappingRepository: MatrixSmsMappingRepository, 15 | private val membershipService: MatrixMembershipService, 16 | private val smsBridgeProperties: SmsBridgeProperties 17 | ) { 18 | 19 | suspend fun getOrCreateMapping( 20 | userId: UserId, 21 | roomId: RoomId 22 | ): MatrixSmsMapping { 23 | val membership = membershipService.getOrCreateMembership(userId, roomId) 24 | val mapping = mappingRepository.findByMembershipId(membership.id) 25 | return if (mapping == null) { 26 | val lastMappingToken = mappingRepository.findByUserIdSortByMappingTokenDesc(userId) 27 | .firstOrNull()?.mappingToken ?: 0 28 | mappingRepository.save(MatrixSmsMapping(membership.id, lastMappingToken + 1)) 29 | } else mapping 30 | } 31 | 32 | suspend fun getRoomId(userId: UserId, mappingToken: Int?): RoomId? { 33 | return if (mappingToken == null) { 34 | findSingleRoomIdMapping(userId) 35 | } else { 36 | val mapping = mappingRepository.findByUserIdAndMappingToken(userId, mappingToken) 37 | if (mapping == null) { 38 | findSingleRoomIdMapping(userId) 39 | } else { 40 | membershipService.getMembership(mapping.membershipId)?.roomId 41 | } 42 | } 43 | } 44 | 45 | private suspend fun findSingleRoomIdMapping(userId: UserId): RoomId? { 46 | return if (smsBridgeProperties.allowMappingWithoutToken) { 47 | val memberships = membershipService.getMembershipsByUserId(userId).take(2).toList() 48 | if (memberships.size == 1) memberships.first().roomId else null 49 | } else null 50 | } 51 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/message/MatrixMessage.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.message 2 | 3 | import net.folivo.matrix.core.model.MatrixId.RoomId 4 | import net.folivo.matrix.core.model.MatrixId.UserId 5 | import org.springframework.data.annotation.Id 6 | import org.springframework.data.annotation.Version 7 | import org.springframework.data.relational.core.mapping.Column 8 | import org.springframework.data.relational.core.mapping.Table 9 | import java.time.Instant 10 | 11 | @Table("matrix_message") 12 | data class MatrixMessage( 13 | @Column("room_id") 14 | val roomId: RoomId, 15 | @Column("body") 16 | val body: String, 17 | @Column("send_after") 18 | val sendAfter: Instant = Instant.now(), 19 | @Column("is_notice") 20 | val isNotice: Boolean = false, 21 | @Column("as_user_id") 22 | val asUserId: UserId? = null, 23 | @Id 24 | @Column("id") 25 | val id: Long? = null, 26 | @Version 27 | @Column("version") 28 | val version: Int = 0 29 | ) -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/message/MatrixMessageReceiver.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.message 2 | 3 | import net.folivo.matrix.core.model.MatrixId.UserId 4 | import org.springframework.data.annotation.Id 5 | import org.springframework.data.annotation.Version 6 | import org.springframework.data.relational.core.mapping.Column 7 | import org.springframework.data.relational.core.mapping.Table 8 | 9 | @Table("matrix_message_receiver") 10 | data class MatrixMessageReceiver( 11 | @Column("room_message_id") 12 | val roomMessageId: Long, 13 | @Column("user_id") 14 | val userId: UserId, 15 | @Id 16 | @Column("id") 17 | val id: Long? = null, 18 | @Version 19 | @Column("version") 20 | val version: Int = 0 21 | ) -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/message/MatrixMessageReceiverRepository.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.message 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 5 | import org.springframework.stereotype.Repository 6 | 7 | @Repository 8 | interface MatrixMessageReceiverRepository : CoroutineCrudRepository { 9 | 10 | fun findByRoomMessageId(roomMessageId: Long): Flow 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/message/MatrixMessageRepository.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.message 2 | 3 | import net.folivo.matrix.core.model.MatrixId.RoomId 4 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 5 | import org.springframework.stereotype.Repository 6 | 7 | @Repository 8 | interface MatrixMessageRepository : CoroutineCrudRepository { 9 | suspend fun deleteByRoomId(roomId: RoomId) 10 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/message/MatrixMessageService.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.message 2 | 3 | import kotlinx.coroutines.flow.collect 4 | import kotlinx.coroutines.flow.map 5 | import kotlinx.coroutines.flow.toSet 6 | import net.folivo.matrix.bot.membership.MatrixMembershipService 7 | import net.folivo.matrix.bot.user.MatrixUserService 8 | import net.folivo.matrix.core.model.MatrixId.RoomId 9 | import net.folivo.matrix.core.model.MatrixId.UserId 10 | import net.folivo.matrix.core.model.events.m.room.message.NoticeMessageEventContent 11 | import net.folivo.matrix.core.model.events.m.room.message.TextMessageEventContent 12 | import net.folivo.matrix.restclient.MatrixClient 13 | import org.slf4j.LoggerFactory 14 | import org.springframework.stereotype.Service 15 | import java.time.Instant 16 | import java.time.temporal.ChronoUnit 17 | 18 | @Service 19 | class MatrixMessageService( 20 | private val messageRepository: MatrixMessageRepository, 21 | private val messageReceiverRepository: MatrixMessageReceiverRepository, 22 | private val membershipService: MatrixMembershipService, 23 | private val userService: MatrixUserService, 24 | private val matrixClient: MatrixClient 25 | ) { 26 | 27 | companion object { 28 | private val LOG = LoggerFactory.getLogger(this::class.java) 29 | } 30 | 31 | suspend fun sendRoomMessage(message: MatrixMessage, requiredMembers: Set = setOf()) { 32 | val isNew = message.id == null 33 | 34 | if (Instant.now().isAfter(message.sendAfter)) { 35 | val roomId = message.roomId 36 | val requiredMembersAndSender = 37 | if (message.asUserId == null) requiredMembers 38 | else setOf(*requiredMembers.toTypedArray(), message.asUserId) 39 | val containsReceiversAndSender = membershipService.doesRoomContainsMembers(roomId, requiredMembersAndSender) 40 | if (containsReceiversAndSender) { 41 | try { 42 | LOG.debug("send cached message to room $roomId and delete from db") 43 | matrixClient.roomsApi.sendRoomEvent( 44 | roomId = roomId, 45 | eventContent = if (message.isNotice) NoticeMessageEventContent(message.body) 46 | else TextMessageEventContent(message.body), 47 | asUserId = message.asUserId 48 | ) 49 | deleteMessage(message) 50 | } catch (error: Throwable) { 51 | LOG.debug( 52 | "Could not send cached message to room $roomId. This happens e.g. when the bot was kicked " + 53 | "out of the room, before the required receivers did join. Error: ${error.message}" 54 | ) 55 | if (message.sendAfter.until(Instant.now(), ChronoUnit.DAYS) > 3) { 56 | LOG.warn( 57 | "We have cached messages for the room $roomId, but sending the message " + 58 | "didn't worked since 3 days. " + 59 | "This usually should never happen! The message will now be deleted." 60 | ) 61 | deleteMessage(message) 62 | // TODO directly notify user 63 | } 64 | } 65 | } else if (isNew) { 66 | saveMessageAndReceivers(message.copy(sendAfter = Instant.now()), requiredMembers) 67 | } else if (message.sendAfter.until(Instant.now(), ChronoUnit.DAYS) > 3) { 68 | LOG.warn( 69 | "We have cached messages for the room $roomId, but the required receivers " + 70 | "${requiredMembers.joinToString()} didn't join since 3 days. " + 71 | "This usually should never happen! The message will now be deleted." 72 | ) 73 | deleteMessage(message) 74 | // TODO directly notify user 75 | } else { 76 | LOG.debug("wait for required receivers to join") 77 | } 78 | } else if (isNew) { 79 | saveMessageAndReceivers(message, requiredMembers) 80 | } 81 | } 82 | 83 | suspend fun saveMessageAndReceivers(message: MatrixMessage, requiredMembers: Set) { 84 | if (message.asUserId != null) userService.getOrCreateUser(message.asUserId) 85 | val savedMessage = messageRepository.save(message) 86 | requiredMembers.forEach { 87 | if (savedMessage.id != null) { 88 | userService.getOrCreateUser(it) 89 | messageReceiverRepository.save(MatrixMessageReceiver(savedMessage.id, it)) 90 | } 91 | } 92 | } 93 | 94 | suspend fun deleteMessage(message: MatrixMessage) { 95 | if (message.id != null) messageRepository.delete(message) 96 | } 97 | 98 | suspend fun deleteByRoomId(roomId: RoomId) { 99 | messageRepository.deleteByRoomId(roomId) 100 | } 101 | 102 | suspend fun processMessageQueue() { 103 | messageRepository.findAll() 104 | .collect { message -> 105 | if (message.id != null) { 106 | val requiredReceivers = messageReceiverRepository.findByRoomMessageId(message.id) 107 | .map { it.userId } 108 | .toSet() 109 | sendRoomMessage(message, requiredReceivers) 110 | } 111 | } 112 | } 113 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/message/MessageQueueHandler.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.message 2 | 3 | import kotlinx.coroutines.GlobalScope 4 | import kotlinx.coroutines.delay 5 | import kotlinx.coroutines.launch 6 | import org.slf4j.LoggerFactory 7 | import org.springframework.boot.context.event.ApplicationReadyEvent 8 | import org.springframework.context.ApplicationListener 9 | import org.springframework.context.annotation.Profile 10 | import org.springframework.stereotype.Component 11 | 12 | @Component 13 | @Profile("!initialsync") 14 | class MessageQueueHandler(private val messageService: MatrixMessageService) : 15 | ApplicationListener { 16 | 17 | companion object { 18 | private val LOG = LoggerFactory.getLogger(this::class.java) 19 | } 20 | 21 | override fun onApplicationEvent(event: ApplicationReadyEvent) { 22 | GlobalScope.launch { 23 | while (true) { 24 | delay(10000) 25 | try { 26 | messageService.processMessageQueue() 27 | } catch (error: Throwable) { 28 | LOG.warn("error while processing messages for deferred sending: ${error.message}") 29 | } 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/SmsProvider.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider 2 | 3 | interface SmsProvider { 4 | suspend fun sendSms(receiver: String, body: String) 5 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidInSmsMessage.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | 5 | data class AndroidInSmsMessage( 6 | @JsonProperty("number") 7 | val sender: String, 8 | @JsonProperty("body") 9 | val body: String, 10 | @JsonProperty 11 | val id: Int, 12 | ) -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidInSmsMessagesResponse.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | 5 | data class AndroidInSmsMessagesResponse( 6 | @JsonProperty("nextBatch") 7 | val nextBatch: Int, 8 | @JsonProperty("messages") 9 | val messages: List 10 | ) -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidOutSmsMessage.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import org.springframework.data.annotation.Id 4 | import org.springframework.data.annotation.Version 5 | import org.springframework.data.relational.core.mapping.Column 6 | import org.springframework.data.relational.core.mapping.Table 7 | 8 | @Table("android_out_sms_message") 9 | data class AndroidOutSmsMessage( 10 | @Column("receiver") 11 | val receiver: String, 12 | @Column("body") 13 | val body: String, 14 | @Id 15 | @Column("id") 16 | val id: Long? = null, 17 | @Version 18 | @Column("version") 19 | var version: Int = 0 20 | ) -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidOutSmsMessageRepository.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 4 | import org.springframework.stereotype.Repository 5 | 6 | @Repository 7 | interface AndroidOutSmsMessageRepository : CoroutineCrudRepository { 8 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidOutSmsMessageRequest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | 5 | data class AndroidOutSmsMessageRequest( 6 | @JsonProperty("recipientPhoneNumber") 7 | val receiver: String, 8 | @JsonProperty("message") 9 | val body: String 10 | ) -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidSmsProcessed.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import org.springframework.data.annotation.Id 4 | import org.springframework.data.annotation.Version 5 | import org.springframework.data.relational.core.mapping.Column 6 | import org.springframework.data.relational.core.mapping.Table 7 | 8 | @Table("android_sms_processed") 9 | data class AndroidSmsProcessed( 10 | @Id 11 | @Column("id") 12 | val id: Long, 13 | @Column("last_processed_id") 14 | var lastProcessedId: Int, 15 | @Version 16 | @Column("version") 17 | var version: Int = 0 18 | ) -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidSmsProcessedRepository.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 4 | import org.springframework.stereotype.Repository 5 | 6 | @Repository 7 | interface AndroidSmsProcessedRepository : CoroutineCrudRepository { 8 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidSmsProvider.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlinx.coroutines.flow.collect 5 | import kotlinx.coroutines.reactive.awaitFirstOrNull 6 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 7 | import net.folivo.matrix.bridge.sms.handler.ReceiveSmsService 8 | import net.folivo.matrix.bridge.sms.provider.SmsProvider 9 | import net.folivo.matrix.core.model.events.m.room.message.NoticeMessageEventContent 10 | import net.folivo.matrix.restclient.MatrixClient 11 | import org.slf4j.LoggerFactory 12 | import org.springframework.http.HttpStatus.BAD_REQUEST 13 | import org.springframework.web.reactive.function.client.WebClient 14 | import org.springframework.web.reactive.function.client.awaitBody 15 | import kotlin.time.ExperimentalTime 16 | import kotlin.time.seconds 17 | 18 | class AndroidSmsProvider( 19 | private val receiveSmsService: ReceiveSmsService, 20 | private val processedRepository: AndroidSmsProcessedRepository, 21 | private val outSmsMessageRepository: AndroidOutSmsMessageRepository, 22 | private val webClient: WebClient, 23 | private val matrixClient: MatrixClient, 24 | private val smsBridgeProperties: SmsBridgeProperties 25 | ) : SmsProvider { 26 | 27 | companion object { 28 | private val LOG = LoggerFactory.getLogger(this::class.java) 29 | } 30 | 31 | override suspend fun sendSms(receiver: String, body: String) { 32 | try { 33 | sendOutSmsMessageRequest(AndroidOutSmsMessageRequest(receiver, body)) 34 | } catch (error: Throwable) { 35 | if (error is AndroidSmsProviderException && error.status == BAD_REQUEST) { 36 | throw error 37 | } else { 38 | LOG.error("could not send sms message to android sms gateway: ${error.message}") 39 | outSmsMessageRepository.save(AndroidOutSmsMessage(receiver, body)) 40 | if (smsBridgeProperties.defaultRoomId != null) { 41 | matrixClient.roomsApi.sendRoomEvent( 42 | smsBridgeProperties.defaultRoomId, 43 | NoticeMessageEventContent( 44 | smsBridgeProperties.templates.providerSendError 45 | .replace("{error}", error.message ?: "unknown") 46 | .replace("{receiver}", receiver) 47 | ) 48 | ) 49 | } 50 | } 51 | } 52 | } 53 | 54 | suspend fun sendOutFailedMessages() { 55 | if (outSmsMessageRepository.count() > 0L) { 56 | outSmsMessageRepository.findAll().collect { 57 | sendOutSmsMessageRequest(AndroidOutSmsMessageRequest(it.receiver, it.body)) 58 | outSmsMessageRepository.delete(it) 59 | delay(smsBridgeProperties.retryQueueDelay * 1000) 60 | } 61 | if (smsBridgeProperties.defaultRoomId != null) { 62 | matrixClient.roomsApi.sendRoomEvent( 63 | smsBridgeProperties.defaultRoomId, 64 | NoticeMessageEventContent(smsBridgeProperties.templates.providerResendSuccess) 65 | ) 66 | } 67 | } 68 | } 69 | 70 | private suspend fun sendOutSmsMessageRequest(message: AndroidOutSmsMessageRequest) { 71 | LOG.debug("start send out sms message via android") 72 | webClient.post().uri("/messages/out").bodyValue(message) 73 | .retrieve().toBodilessEntity().awaitFirstOrNull() 74 | LOG.debug("send out sms message via android was successful") 75 | } 76 | 77 | suspend fun getAndProcessNewMessages() { 78 | LOG.debug("request new messages") 79 | val lastProcessed = processedRepository.findById(1) 80 | val response = webClient.get().uri { 81 | it.apply { 82 | path("/messages/in") 83 | if (lastProcessed != null) queryParam("after", lastProcessed.lastProcessedId) 84 | }.build() 85 | }.retrieve().awaitBody() 86 | response.messages 87 | .sortedBy { it.id } 88 | .fold(lastProcessed, { process, message -> 89 | val answer = receiveSmsService.receiveSms( 90 | message.body, 91 | message.sender 92 | ) 93 | try { 94 | if (answer != null) sendSms(message.sender, answer) 95 | } catch (error: Throwable) { 96 | LOG.error("could not answer ${message.sender} with message $answer. Reason: ${error.message}") 97 | LOG.debug("details:", error) 98 | } 99 | processedRepository.save( 100 | process?.copy(lastProcessedId = message.id) 101 | ?: AndroidSmsProcessed(1, message.id) 102 | ) 103 | }) 104 | LOG.debug("processed new messages") 105 | } 106 | 107 | 108 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidSmsProviderConfiguration.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import io.netty.handler.ssl.SslContextBuilder 4 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 5 | import net.folivo.matrix.bridge.sms.handler.ReceiveSmsService 6 | import net.folivo.matrix.restclient.MatrixClient 7 | import org.springframework.beans.factory.annotation.Qualifier 8 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 9 | import org.springframework.boot.context.properties.EnableConfigurationProperties 10 | import org.springframework.context.annotation.Bean 11 | import org.springframework.context.annotation.Configuration 12 | import org.springframework.context.annotation.Profile 13 | import org.springframework.http.HttpHeaders 14 | import org.springframework.http.MediaType 15 | import org.springframework.http.client.reactive.ReactorClientHttpConnector 16 | import org.springframework.web.reactive.function.client.ClientResponse 17 | import org.springframework.web.reactive.function.client.ExchangeFilterFunction 18 | import org.springframework.web.reactive.function.client.WebClient 19 | import reactor.core.publisher.Mono 20 | import reactor.netty.http.client.HttpClient 21 | import java.nio.file.Files 22 | import java.nio.file.Path 23 | import java.security.KeyStore 24 | import java.util.* 25 | import javax.net.ssl.TrustManagerFactory 26 | 27 | 28 | @Profile("!initialsync") 29 | @Configuration 30 | @ConditionalOnProperty(prefix = "matrix.bridge.sms.provider.android", name = ["enabled"], havingValue = "true") 31 | @EnableConfigurationProperties(AndroidSmsProviderProperties::class) 32 | class AndroidSmsProviderConfiguration(private val properties: AndroidSmsProviderProperties) { 33 | 34 | @Bean("androidSmsProviderWebClient") 35 | fun androidSmsProviderWebClient(webClientBuilder: WebClient.Builder): WebClient { 36 | val builder = webClientBuilder 37 | .baseUrl(properties.baseUrl) 38 | .defaultHeader( 39 | HttpHeaders.AUTHORIZATION, 40 | "Basic " + Base64.getEncoder() 41 | .encodeToString("${properties.username}:${properties.password}".toByteArray()) 42 | ) 43 | .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) 44 | .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) 45 | .filter(ExchangeFilterFunction.ofResponseProcessor { clientResponse: ClientResponse -> 46 | val statusCode = clientResponse.statusCode() 47 | if (clientResponse.statusCode().isError) { 48 | clientResponse.bodyToMono(String::class.java) 49 | .flatMap { 50 | Mono.error(AndroidSmsProviderException(it, statusCode)) 51 | } 52 | } else { 53 | Mono.just(clientResponse) 54 | } 55 | }) 56 | 57 | val trustStoreProps = properties.trustStore 58 | if (trustStoreProps != null) { 59 | val keyStore = KeyStore.getInstance(trustStoreProps.type) 60 | keyStore.load( 61 | Files.newInputStream(Path.of(trustStoreProps.path)), 62 | trustStoreProps.password.toCharArray() 63 | ) 64 | val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) 65 | factory.init(keyStore) 66 | val sslContext = SslContextBuilder.forClient() 67 | .trustManager(factory) 68 | .build() 69 | val client = HttpClient.create().secure { spec -> spec.sslContext(sslContext) } 70 | builder.clientConnector(ReactorClientHttpConnector(client)) 71 | } 72 | 73 | return builder.build(); 74 | } 75 | 76 | @Bean 77 | fun androidSmsProvider( 78 | receiveSmsService: ReceiveSmsService, 79 | processedRepository: AndroidSmsProcessedRepository, 80 | outSmsMessageRepository: AndroidOutSmsMessageRepository, 81 | @Qualifier("androidSmsProviderWebClient") 82 | webClient: WebClient, 83 | matrixClient: MatrixClient, 84 | smsBridgeProperties: SmsBridgeProperties 85 | ): AndroidSmsProvider { 86 | return AndroidSmsProvider( 87 | receiveSmsService, 88 | processedRepository, 89 | outSmsMessageRepository, 90 | webClient, 91 | matrixClient, 92 | smsBridgeProperties 93 | ) 94 | } 95 | 96 | @Bean 97 | fun smsProviderLauncher( 98 | androidSmsProvider: AndroidSmsProvider, 99 | smsBridgeProperties: SmsBridgeProperties, 100 | matrixClient: MatrixClient 101 | ): AndroidSmsProviderLauncher { 102 | return AndroidSmsProviderLauncher(androidSmsProvider, smsBridgeProperties, matrixClient) 103 | } 104 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidSmsProviderException.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import org.springframework.http.HttpStatus 4 | 5 | class AndroidSmsProviderException(message: String?, val status: HttpStatus) : Throwable(message) -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidSmsProviderLauncher.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import com.github.michaelbull.retry.ContinueRetrying 4 | import com.github.michaelbull.retry.policy.RetryPolicy 5 | import com.github.michaelbull.retry.policy.binaryExponentialBackoff 6 | import com.github.michaelbull.retry.policy.plus 7 | import com.github.michaelbull.retry.retry 8 | import kotlinx.coroutines.GlobalScope 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.launch 12 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 13 | import net.folivo.matrix.core.model.events.m.room.message.NoticeMessageEventContent 14 | import net.folivo.matrix.restclient.MatrixClient 15 | import org.slf4j.LoggerFactory 16 | import org.springframework.boot.context.event.ApplicationReadyEvent 17 | import org.springframework.context.event.EventListener 18 | 19 | class AndroidSmsProviderLauncher( 20 | private val androidSmsProvider: AndroidSmsProvider, 21 | private val smsBridgeProperties: SmsBridgeProperties, 22 | private val matrixClient: MatrixClient 23 | ) { 24 | 25 | companion object { 26 | private val LOG = LoggerFactory.getLogger(this::class.java) 27 | } 28 | 29 | @EventListener(ApplicationReadyEvent::class) 30 | fun startReceiveLoop(): Job { 31 | return GlobalScope.launch { 32 | while (true) { 33 | retry(binaryExponentialBackoff(base = 5000, max = 60000) + logReceiveAttempt()) { 34 | androidSmsProvider.getAndProcessNewMessages() 35 | delay(5000) 36 | } 37 | } 38 | } 39 | } 40 | 41 | @EventListener(ApplicationReadyEvent::class) 42 | fun startRetrySendLoop(): Job { 43 | return GlobalScope.launch { 44 | while (true) { 45 | retry(binaryExponentialBackoff(base = 10000, max = 120000) + logSendAttempt()) { 46 | androidSmsProvider.sendOutFailedMessages() 47 | delay(10000) 48 | } 49 | } 50 | } 51 | } 52 | 53 | private fun logReceiveAttempt(): RetryPolicy { 54 | return { 55 | LOG.error("could not retrieve messages from android device or process them: ${reason.message}") 56 | LOG.debug("detailed error", reason) 57 | try { 58 | if (smsBridgeProperties.defaultRoomId != null) 59 | matrixClient.roomsApi.sendRoomEvent( 60 | smsBridgeProperties.defaultRoomId, 61 | NoticeMessageEventContent( 62 | smsBridgeProperties.templates.providerReceiveError 63 | .replace("{error}", reason.message ?: "unknown") 64 | ) 65 | ) 66 | } catch (error: Throwable) { 67 | LOG.error("could not warn user in default room: ${error.message}") 68 | } 69 | ContinueRetrying 70 | } 71 | } 72 | 73 | private fun logSendAttempt(): RetryPolicy { 74 | return { 75 | LOG.error("could not send messages to android device: ${reason.message}") 76 | LOG.debug("detailed error", reason) 77 | ContinueRetrying 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidSmsProviderProperties.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | import org.springframework.boot.context.properties.ConstructorBinding 5 | 6 | @ConfigurationProperties("matrix.bridge.sms.provider.android") 7 | @ConstructorBinding 8 | data class AndroidSmsProviderProperties( 9 | val enabled: Boolean = false, 10 | val baseUrl: String, 11 | val username: String, 12 | val password: String, 13 | val trustStore: TrustStore? = null 14 | ) { 15 | data class TrustStore( 16 | val path: String, 17 | val password: String, 18 | val type: String 19 | ) 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/gammu/GammuSmsProvider.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.gammu 2 | 3 | import kotlinx.coroutines.reactor.mono 4 | import net.folivo.matrix.bridge.sms.handler.ReceiveSmsService 5 | import net.folivo.matrix.bridge.sms.provider.SmsProvider 6 | import net.folivo.matrix.core.api.ErrorResponse 7 | import net.folivo.matrix.core.api.MatrixServerException 8 | import org.slf4j.LoggerFactory 9 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 10 | import org.springframework.boot.context.event.ApplicationReadyEvent 11 | import org.springframework.boot.context.properties.EnableConfigurationProperties 12 | import org.springframework.context.annotation.Profile 13 | import org.springframework.context.event.EventListener 14 | import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR 15 | import org.springframework.stereotype.Component 16 | import reactor.core.Disposable 17 | import reactor.core.publisher.Flux 18 | import reactor.core.publisher.Mono 19 | import java.io.File 20 | import java.nio.file.Files 21 | import java.nio.file.Path 22 | import java.nio.file.StandardCopyOption 23 | import java.time.Duration 24 | import java.util.concurrent.TimeUnit 25 | import kotlin.text.Charsets.UTF_8 26 | 27 | 28 | @Profile("!initialsync") 29 | @Component 30 | @ConditionalOnProperty(prefix = "matrix.bridge.sms.provider.gammu", name = ["enabled"], havingValue = "true") 31 | @EnableConfigurationProperties(GammuSmsProviderProperties::class) 32 | class GammuSmsProvider( 33 | private val properties: GammuSmsProviderProperties, 34 | private val receiveSmsService: ReceiveSmsService 35 | ) : SmsProvider { 36 | 37 | companion object { 38 | private val LOG = LoggerFactory.getLogger(this::class.java) 39 | } 40 | 41 | init { 42 | LOG.info("Using Gammu as SmsProvider.") 43 | } 44 | 45 | private var disposable: Disposable? = null 46 | 47 | @EventListener(ApplicationReadyEvent::class) 48 | fun startNewMessageLookupLoop() { 49 | disposable = Mono.just(true) // TODO is there a less hacky way? Without that line, repeat does not work 50 | .flatMapMany { Flux.fromStream(Files.list(Path.of(properties.inboxPath))) } 51 | .map { it.toFile() } 52 | .flatMap { file -> 53 | val name = file.name 54 | val sender = name.substringBeforeLast('_').substringAfterLast('_') 55 | Flux.fromStream(Files.lines(file.toPath(), UTF_8)) 56 | .skipUntil { it.startsWith("[SMSBackup000]") } 57 | .filter { it.startsWith("; ") } 58 | .map { it.removePrefix("; ") } 59 | .collectList() 60 | .map { Pair(sender, it.joinToString(separator = "")) } 61 | .doOnSuccess { 62 | Files.move( 63 | file.toPath(), 64 | Path.of(properties.inboxProcessedPath, file.name), 65 | StandardCopyOption.REPLACE_EXISTING 66 | ) 67 | } 68 | }.flatMap { message -> 69 | receiveSms(message.first, message.second) 70 | } 71 | .doOnComplete { LOG.debug("read inbox") } 72 | .doOnError { LOG.error("something happened while scanning directories for new sms", it) } 73 | .delaySubscription(Duration.ofSeconds(10)) 74 | .repeat() 75 | .retry() 76 | .subscribe() 77 | } 78 | 79 | override suspend fun sendSms(receiver: String, body: String) { 80 | var exitCode: Int 81 | val output = try { 82 | ProcessBuilder( 83 | listOf( 84 | "gammu-smsd-inject", 85 | "-c", 86 | properties.configFile, 87 | "TEXT", 88 | receiver, 89 | "-len", 90 | body.length.toString(), 91 | "-unicode", // TODO maybe don't sent everything in unicode (it allows more characters per sms) 92 | "-text", 93 | body 94 | ) 95 | ).directory(File(".")) 96 | .redirectOutput(ProcessBuilder.Redirect.PIPE) 97 | .redirectError(ProcessBuilder.Redirect.PIPE) 98 | .start().apply { 99 | waitFor(10, TimeUnit.SECONDS) 100 | exitCode = exitValue() 101 | } 102 | .inputStream.bufferedReader().readText() 103 | } catch (error: Throwable) { 104 | LOG.error("some unhandled exception occurred during running send sms shell command", error) 105 | throw MatrixServerException( 106 | INTERNAL_SERVER_ERROR, 107 | ErrorResponse("NET.FOLIVO_UNKNOWN", error.message) 108 | ) 109 | } 110 | if (exitCode != 0) { 111 | throw MatrixServerException( 112 | INTERNAL_SERVER_ERROR, 113 | ErrorResponse( 114 | "NET.FOLIVO_INTERNAL_SERVER_ERROR", 115 | "exitCode was $exitCode due to $output" 116 | ) 117 | ) 118 | } else { 119 | LOG.debug(output) 120 | return 121 | } 122 | 123 | } 124 | 125 | fun receiveSms( 126 | sender: String, 127 | body: String 128 | ): Mono { 129 | return mono { 130 | receiveSmsService.receiveSms(body = body, providerSender = sender) 131 | ?.also { sendSms(receiver = sender, body = it) } 132 | }.then() 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/main/kotlin/net/folivo/matrix/bridge/sms/provider/gammu/GammuSmsProviderProperties.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.gammu 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | import org.springframework.boot.context.properties.ConstructorBinding 5 | 6 | @ConfigurationProperties("matrix.bridge.sms.provider.gammu") 7 | @ConstructorBinding 8 | data class GammuSmsProviderProperties( 9 | val enabled: Boolean = false, 10 | val inboxPath: String = "/data/spool/inbox", 11 | val inboxProcessedPath: String = "/data/spool/inbox_processed", 12 | val configFile: String = "/etc/gammu/gammu-smsdrc-modem1" 13 | ) 14 | -------------------------------------------------------------------------------- /src/main/resources/application-initialsync.yml: -------------------------------------------------------------------------------- 1 | matrix: 2 | bot: 3 | mode: APPSERVICE 4 | username: smsbot 5 | trackMembership: ALL 6 | appservice: 7 | namespaces: 8 | users: 9 | - localpartRegex: "sms_[0-9]{6,15}" 10 | rooms: 11 | - localpartRegex: "sms_[0-9]{6,15}" 12 | aliases: [ ] 13 | 14 | spring: 15 | autoconfigure: 16 | exclude: org.springframework.boot.autoconfigure.data.jdbc.JdbcRepositoriesAutoConfiguration,org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | matrix: 2 | bot: 3 | mode: APPSERVICE 4 | username: smsbot 5 | trackMembership: ALL 6 | appservice: 7 | namespaces: 8 | users: 9 | - localpartRegex: "sms_[0-9]{6,15}" 10 | rooms: 11 | - localpartRegex: "sms_[0-9]{6,15}" 12 | aliases: [ ] 13 | 14 | spring: 15 | autoconfigure: 16 | exclude: org.springframework.boot.autoconfigure.data.jdbc.JdbcRepositoriesAutoConfiguration,org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration -------------------------------------------------------------------------------- /src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | __ __ ______ ______ ______ __ __ __ 2 | /\ "-./ \ /\ __ \ /\__ _\ /\ == \ /\ \ /\_\_\_\ 3 | \ \ \-./\ \ \ \ __ \ \/_/\ \/ \ \ __< \ \ \ \/_/\_\/_ 4 | \ \_\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ \_\ \ \_\ /\_\/\_\ 5 | \/_/ \/_/ \/_/\/_/ \/_/ \/_/ /_/ \/_/ \/_/\/_/ 6 | ______ __ __ ______ 7 | /\ ___\ /\ "-./ \ /\ ___\ 8 | \ \___ \ \ \ \-./\ \ \ \___ \ [[Version: ${application.version}]] 9 | \/\_____\ \ \_\ \ \_\ \/\_____\ 10 | \/_____/ \/_/ \/_/ \/_____/ 11 | ______ ______ __ _____ ______ ______ 12 | /\ == \ /\ == \ /\ \ /\ __-. /\ ___\ /\ ___\ 13 | \ \ __< \ \ __< \ \ \ \ \ \/\ \ \ \ \__ \ \ \ __\ 14 | \ \_____\ \ \_\ \_\ \ \_\ \ \____- \ \_____\ \ \_____\ 15 | \/_____/ \/_/ /_/ \/_/ \/____/ \/_____/ \/_____/ 16 | 17 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/net.folivo.matrix.bridge.sms.changelog-0.4.0.RELEASE.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: net.folivo.matrix.bridge.sms.changelog-0.4.0.RELEASE 4 | author: benkuly 5 | changes: 6 | - createTable: 7 | tableName: matrix_message 8 | columns: 9 | - column: 10 | name: id 11 | type: BIGINT 12 | autoIncrement: true 13 | constraints: 14 | nullable: false 15 | primaryKey: true 16 | - column: 17 | name: room_id 18 | type: VARCHAR(250) 19 | constraints: 20 | nullable: false 21 | - column: 22 | name: body 23 | type: VARCHAR(250) 24 | constraints: 25 | nullable: false 26 | - column: 27 | name: send_after 28 | type: DATETIME 29 | constraints: 30 | nullable: false 31 | - column: 32 | name: is_notice 33 | type: BOOLEAN 34 | constraints: 35 | nullable: false 36 | - column: 37 | name: as_user_id 38 | type: VARCHAR(250) 39 | constraints: 40 | nullable: true 41 | - column: 42 | name: version 43 | type: INTEGER 44 | constraints: 45 | nullable: false 46 | - addForeignKeyConstraint: 47 | baseTableName: matrix_message 48 | baseColumnNames: room_id 49 | constraintName: fk_matrix_message_matrix_room 50 | onDelete: CASCADE 51 | referencedTableName: matrix_room 52 | referencedColumnNames: id 53 | - addForeignKeyConstraint: 54 | baseTableName: matrix_message 55 | baseColumnNames: as_user_id 56 | constraintName: fk_matrix_message_matrix_user 57 | onDelete: CASCADE 58 | referencedTableName: matrix_user 59 | referencedColumnNames: id 60 | - createTable: 61 | tableName: matrix_message_receiver 62 | columns: 63 | - column: 64 | name: id 65 | type: BIGINT 66 | autoIncrement: true 67 | constraints: 68 | nullable: false 69 | primaryKey: true 70 | - column: 71 | name: room_message_id 72 | type: BIGINT 73 | constraints: 74 | nullable: false 75 | - column: 76 | name: user_id 77 | type: VARCHAR(250) 78 | constraints: 79 | nullable: false 80 | - column: 81 | name: version 82 | type: INTEGER 83 | constraints: 84 | nullable: false 85 | - addForeignKeyConstraint: 86 | baseTableName: matrix_message_receiver 87 | baseColumnNames: room_message_id 88 | constraintName: fk_matrix_message_receiver_matrix_message 89 | onDelete: CASCADE 90 | referencedTableName: matrix_message 91 | referencedColumnNames: id 92 | - addForeignKeyConstraint: 93 | baseTableName: matrix_message_receiver 94 | baseColumnNames: user_id 95 | constraintName: fk_matrix_message_receiver_matrix_user 96 | onDelete: CASCADE 97 | referencedTableName: matrix_user 98 | referencedColumnNames: id 99 | - createTable: 100 | tableName: matrix_sms_mapping 101 | columns: 102 | - column: 103 | name: membership_id 104 | type: VARCHAR(250) 105 | constraints: 106 | nullable: false 107 | - column: 108 | name: mapping_token 109 | type: INTEGER 110 | constraints: 111 | nullable: true 112 | - column: 113 | name: version 114 | type: INTEGER 115 | constraints: 116 | nullable: false 117 | - addForeignKeyConstraint: 118 | baseTableName: matrix_sms_mapping 119 | baseColumnNames: membership_id 120 | constraintName: fk_matrix_sms_mapping_matrix_membership 121 | onDelete: CASCADE 122 | referencedTableName: matrix_membership 123 | referencedColumnNames: id -------------------------------------------------------------------------------- /src/main/resources/db/changelog/net.folivo.matrix.bridge.sms.changelog-0.4.2.RELEASE.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: net.folivo.matrix.bridge.sms.changelog-0.4.2.RELEASE 4 | author: benkuly 5 | changes: 6 | - modifyDataType: 7 | tableName: matrix_message 8 | columnName: body 9 | newDataType: VARCHAR(2000) -------------------------------------------------------------------------------- /src/main/resources/db/changelog/net.folivo.matrix.bridge.sms.changelog-0.5.0.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: net.folivo.matrix.bridge.sms.changelog-0.5.0 4 | author: benkuly 5 | changes: 6 | - createTable: 7 | tableName: android_sms_processed 8 | columns: 9 | - column: 10 | name: id 11 | type: BIGINT 12 | constraints: 13 | nullable: false 14 | primaryKey: true 15 | - column: 16 | name: last_processed_id 17 | type: INTEGER 18 | constraints: 19 | nullable: false 20 | - column: 21 | name: version 22 | type: INTEGER 23 | constraints: 24 | nullable: false 25 | - createTable: 26 | tableName: android_out_sms_message 27 | columns: 28 | - column: 29 | name: id 30 | type: BIGINT 31 | autoIncrement: true 32 | constraints: 33 | nullable: false 34 | primaryKey: true 35 | - column: 36 | name: body 37 | type: VARCHAR(2000) 38 | constraints: 39 | nullable: false 40 | - column: 41 | name: receiver 42 | type: VARCHAR(250) 43 | constraints: 44 | nullable: false 45 | - column: 46 | name: version 47 | type: INTEGER 48 | constraints: 49 | nullable: false -------------------------------------------------------------------------------- /src/main/resources/db/changelog/net.folivo.matrix.bridge.sms.changelog-master.yml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - include: 3 | file: db/changelog/net.folivo.matrix.bridge.sms.changelog-0.4.0.RELEASE.yaml 4 | - include: 5 | file: db/changelog/net.folivo.matrix.bridge.sms.changelog-0.4.2.RELEASE.yaml 6 | - include: 7 | file: db/changelog/net.folivo.matrix.bridge.sms.changelog-0.5.0.yaml -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/KotestConfig.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms 2 | 3 | import io.kotest.core.config.AbstractProjectConfig 4 | import io.kotest.core.extensions.Extension 5 | import io.kotest.spring.SpringAutowireConstructorExtension 6 | 7 | class KotestConfig : AbstractProjectConfig() { 8 | override fun extensions(): List = listOf(SpringAutowireConstructorExtension) 9 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/appservice/SmsMatrixAppserviceRoomServiceTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.appservice 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.kotest.matchers.collections.shouldContainExactly 5 | import io.kotest.matchers.maps.shouldContainExactly 6 | import io.kotest.matchers.nulls.shouldNotBeNull 7 | import io.kotest.matchers.shouldBe 8 | import io.mockk.clearMocks 9 | import io.mockk.every 10 | import io.mockk.mockk 11 | import net.folivo.matrix.appservice.api.room.AppserviceRoomService.RoomExistingState.DOES_NOT_EXISTS 12 | import net.folivo.matrix.bot.config.MatrixBotProperties 13 | import net.folivo.matrix.bot.room.MatrixRoomService 14 | import net.folivo.matrix.bot.util.BotServiceHelper 15 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 16 | import net.folivo.matrix.core.model.MatrixId.RoomAliasId 17 | import net.folivo.matrix.core.model.MatrixId.UserId 18 | import net.folivo.matrix.restclient.api.rooms.Visibility.PRIVATE 19 | 20 | class SmsMatrixAppserviceRoomServiceTest : DescribeSpec(testBody()) 21 | 22 | private fun testBody(): DescribeSpec.() -> Unit { 23 | return { 24 | val botUserId = UserId("bot", "server") 25 | val roomServiceMock: MatrixRoomService = mockk() 26 | val helperMock: BotServiceHelper = mockk() 27 | val botPropertiesMock: MatrixBotProperties = mockk { 28 | every { serverName }.returns("server") 29 | } 30 | every { botPropertiesMock.botUserId }.returns(botUserId) 31 | 32 | val bridgePropertiesMock: SmsBridgeProperties = mockk() 33 | 34 | val cut = SmsMatrixAppserviceRoomService(roomServiceMock, helperMock, botPropertiesMock, bridgePropertiesMock) 35 | 36 | describe(SmsMatrixAppserviceRoomService::getCreateRoomParameter.name) { 37 | val result = cut.getCreateRoomParameter(RoomAliasId("alias", "domain")) 38 | val userId = UserId("alias", "server") 39 | 40 | it("should invite matching user and give admin rights") { 41 | result.invite.shouldContainExactly(userId) 42 | result.powerLevelContentOverride?.users?.get(userId).shouldBe(100) 43 | } 44 | it("visibility should be private") { 45 | result.visibility.shouldBe(PRIVATE) 46 | } 47 | it("user rights should be set") { 48 | result.powerLevelContentOverride?.invite.shouldBe(0) 49 | result.powerLevelContentOverride?.kick.shouldBe(0) 50 | result.powerLevelContentOverride?.events.shouldNotBeNull() 51 | result.powerLevelContentOverride?.events?.shouldContainExactly( 52 | mapOf("m.room.name" to 0, "m.room.topic" to 0) 53 | ) 54 | result.powerLevelContentOverride?.users.shouldBe( 55 | mapOf( 56 | botUserId to 100, 57 | userId to 100 58 | ) 59 | ) 60 | } 61 | } 62 | describe(SmsMatrixAppserviceRoomService::roomExistingState.name) { 63 | val roomAliasId = RoomAliasId("alias", "server") 64 | it("should deny room createion when single mode disabled") { 65 | every { bridgePropertiesMock.singleModeEnabled }.returns(false) 66 | cut.roomExistingState(roomAliasId).shouldBe(DOES_NOT_EXISTS) 67 | } 68 | } 69 | 70 | afterTest { clearMocks(bridgePropertiesMock) } 71 | } 72 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/appservice/SmsMatrixAppserviceUserServiceTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.appservice 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockk.every 6 | import io.mockk.mockk 7 | import net.folivo.matrix.bot.config.MatrixBotProperties 8 | import net.folivo.matrix.bot.user.MatrixUserService 9 | import net.folivo.matrix.bot.util.BotServiceHelper 10 | import net.folivo.matrix.core.model.MatrixId.UserId 11 | 12 | class SmsMatrixAppserviceUserServiceTest : DescribeSpec(testBody()) 13 | 14 | private fun testBody(): DescribeSpec.() -> Unit { 15 | return { 16 | val userServiceMock: MatrixUserService = mockk() 17 | val helperMock: BotServiceHelper = mockk() 18 | val botPropertiesMock: MatrixBotProperties = mockk() { 19 | every { botUserId }.returns(UserId("bot", "server")) 20 | } 21 | val cut = SmsMatrixAppserviceUserService(userServiceMock, helperMock, botPropertiesMock) 22 | 23 | describe(SmsMatrixAppserviceUserService::getRegisterUserParameter.name) { 24 | describe("user is bot") { 25 | val result = cut.getRegisterUserParameter(UserId("bot", "server")) 26 | 27 | it("should return display name") { 28 | result.displayName.shouldBe("SMS Bot") 29 | } 30 | } 31 | describe("user is not bot") { 32 | val result = cut.getRegisterUserParameter(UserId("sms_49123456", "server")) 33 | 34 | it("should return display name") { 35 | result.displayName.shouldBe("+49123456 (SMS)") 36 | } 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/appservice/SmsMatrixMembershipChangeServiceTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.appservice 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.kotest.matchers.booleans.shouldBeFalse 5 | import io.kotest.matchers.booleans.shouldBeTrue 6 | import io.mockk.clearMocks 7 | import io.mockk.coEvery 8 | import io.mockk.every 9 | import io.mockk.mockk 10 | import net.folivo.matrix.bot.config.MatrixBotProperties 11 | import net.folivo.matrix.bot.room.MatrixRoomAlias 12 | import net.folivo.matrix.bot.room.MatrixRoomService 13 | import net.folivo.matrix.core.model.MatrixId.* 14 | 15 | class SmsMatrixMembershipChangeServiceTest : DescribeSpec(testBody()) 16 | 17 | private fun testBody(): DescribeSpec.() -> Unit { 18 | return { 19 | val roomServiceMock: MatrixRoomService = mockk() 20 | val botPropertiesMock: MatrixBotProperties = mockk { 21 | every { botUserId } returns UserId("bot", "server") 22 | } 23 | 24 | val cut = SmsMatrixMembershipChangeService( 25 | roomServiceMock, 26 | mockk(), 27 | mockk(), 28 | mockk(), 29 | mockk(), 30 | botPropertiesMock 31 | ) 32 | 33 | val roomId = RoomId("room", "server") 34 | describe(SmsMatrixMembershipChangeService::shouldJoinRoom.name) { 35 | it("user is bot") { 36 | cut.shouldJoinRoom(UserId("bot", "server"), roomId) 37 | .shouldBeTrue() 38 | } 39 | it("alias is that from user") { 40 | coEvery { roomServiceMock.getRoomAliasByRoomId(roomId) } 41 | .returns(MatrixRoomAlias(RoomAliasId("sms_111111", "server"), roomId)) 42 | cut.shouldJoinRoom(UserId("sms_111111", "server"), roomId) 43 | .shouldBeTrue() 44 | } 45 | it("alias is not that from user") { 46 | coEvery { roomServiceMock.getRoomAliasByRoomId(roomId) } 47 | .returns(MatrixRoomAlias(RoomAliasId("sms_222222", "server"), roomId)) 48 | cut.shouldJoinRoom(UserId("sms_111111", "server"), roomId) 49 | .shouldBeFalse() 50 | } 51 | } 52 | 53 | 54 | afterTest { clearMocks(roomServiceMock) } 55 | } 56 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/handler/MessageToBotHandlerTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.kotest.matchers.booleans.shouldBeFalse 5 | import io.kotest.matchers.booleans.shouldBeTrue 6 | import io.mockk.* 7 | import net.folivo.matrix.bot.event.MessageContext 8 | import net.folivo.matrix.bot.membership.MatrixMembershipService 9 | import net.folivo.matrix.bot.user.MatrixUser 10 | import net.folivo.matrix.bot.user.MatrixUserService 11 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 12 | import net.folivo.matrix.core.model.MatrixId.* 13 | 14 | class MessageToBotHandlerTest : DescribeSpec(testBody()) 15 | 16 | private fun testBody(): DescribeSpec.() -> Unit { 17 | return { 18 | val smsSendCommandHandlerMock: SmsSendCommandHandler = mockk() 19 | val smsInviteCommandHandlerMock: SmsInviteCommandHandler = mockk() 20 | val smsAbortCommandHandlerMock: SmsAbortCommandHandler = mockk() 21 | val phoneNumberServiceMock: PhoneNumberService = mockk() 22 | val smsBridgePropertiesMock: SmsBridgeProperties = mockk { 23 | every { templates.botHelp }.returns("help") 24 | every { templates.botSmsError }.returns("error") 25 | every { templates.botTooManyMembers }.returns("toMany") 26 | } 27 | val userServiceMock: MatrixUserService = mockk() 28 | val membershipServiceMock: MatrixMembershipService = mockk() 29 | 30 | val cut = MessageToBotHandler( 31 | smsSendCommandHandlerMock, 32 | smsInviteCommandHandlerMock, 33 | smsAbortCommandHandlerMock, 34 | phoneNumberServiceMock, 35 | smsBridgePropertiesMock, 36 | userServiceMock, 37 | membershipServiceMock 38 | ) 39 | 40 | val contextMock: MessageContext = mockk() 41 | val roomId = RoomId("room", "server") 42 | val senderId = UserId("sender", "server") 43 | 44 | beforeTest { 45 | coEvery { contextMock.answer(any(), any()) }.returns(EventId("message", "server")) 46 | } 47 | 48 | describe(MessageToBotHandler::handleMessage.name) { 49 | describe("sender is managed") { 50 | it("should do nothing and return false") { 51 | coEvery { userServiceMock.getOrCreateUser(senderId) }.returns(MatrixUser(senderId, true)) 52 | coEvery { membershipServiceMock.getMembershipsSizeByRoomId(roomId) }.returns(2L) 53 | cut.handleMessage(roomId, "sms", senderId, contextMock).shouldBeFalse() 54 | coVerifyAll { 55 | smsSendCommandHandlerMock wasNot Called 56 | smsInviteCommandHandlerMock wasNot Called 57 | } 58 | } 59 | } 60 | describe("to many members in room") { 61 | beforeTest { 62 | coEvery { membershipServiceMock.getMembershipsSizeByRoomId(roomId) }.returns(3L) 63 | coEvery { userServiceMock.getOrCreateUser(senderId) }.returns(MatrixUser(senderId)) 64 | } 65 | it("should warn user and return true") { 66 | cut.handleMessage(roomId, "sms", senderId, contextMock).shouldBeTrue() 67 | coVerifyAll { 68 | smsSendCommandHandlerMock wasNot Called 69 | smsInviteCommandHandlerMock wasNot Called 70 | contextMock.answer("toMany") 71 | } 72 | } 73 | it("should accept sms abort command") { 74 | coEvery { smsAbortCommandHandlerMock.handleCommand(any()) } 75 | .returns("aborted") 76 | cut.handleMessage( 77 | roomId, 78 | "sms abort", 79 | senderId, 80 | contextMock 81 | ).shouldBeTrue() 82 | 83 | coVerify(exactly = 1) { 84 | contextMock.answer("aborted") 85 | } 86 | } 87 | } 88 | describe("valid sms command") { 89 | beforeTest { 90 | coEvery { userServiceMock.getOrCreateUser(senderId) }.returns(MatrixUser(senderId)) 91 | coEvery { membershipServiceMock.getMembershipsSizeByRoomId(roomId) }.returns(2L) 92 | } 93 | it("should run sms send command") { 94 | coEvery { smsSendCommandHandlerMock.handleCommand(any(), any(), any(), any(), any(), any(), any()) } 95 | .returns("message send") 96 | every { smsBridgePropertiesMock.defaultRegion }.returns("DE") 97 | every { phoneNumberServiceMock.parseToInternationalNumber(any()) }.returns("+4917392837462") 98 | cut.handleMessage( 99 | roomId, 100 | "sms send -t 017392837462 'some Text'", 101 | senderId, 102 | contextMock 103 | ).shouldBeTrue() 104 | 105 | coVerify(exactly = 1) { 106 | contextMock.answer("message send") 107 | } 108 | } 109 | it("should run sms invite command") { 110 | coEvery { smsInviteCommandHandlerMock.handleCommand(any(), any()) } 111 | .returns("invited") 112 | cut.handleMessage( 113 | roomId, 114 | "sms invite #sms_1739283746:server", 115 | senderId, 116 | contextMock 117 | ).shouldBeTrue() 118 | 119 | coVerify(exactly = 1) { 120 | contextMock.answer("invited") 121 | } 122 | } 123 | it("should catch errors from command") { 124 | cut.handleMessage(roomId, "sms send bla", senderId, contextMock).shouldBeTrue() 125 | coVerify { 126 | contextMock.answer(match { it.contains("Error") }) 127 | } 128 | } 129 | it("should catch errors from unparseable command") { 130 | cut.handleMessage(roomId, "sms send \" bla", senderId, contextMock).shouldBeTrue() 131 | coVerify { 132 | contextMock.answer("error") 133 | } 134 | } 135 | } 136 | describe("two members but no sms command") { 137 | it("should warn user and return true") { 138 | coEvery { userServiceMock.getOrCreateUser(senderId) }.returns(MatrixUser(senderId)) 139 | coEvery { membershipServiceMock.getMembershipsSizeByRoomId(roomId) }.returns(2L) 140 | cut.handleMessage(roomId, "dino", senderId, contextMock).shouldBeTrue() 141 | coVerifyAll { 142 | smsSendCommandHandlerMock wasNot Called 143 | smsInviteCommandHandlerMock wasNot Called 144 | contextMock.answer("help") 145 | } 146 | } 147 | } 148 | } 149 | 150 | afterTest { 151 | clearMocks( 152 | smsSendCommandHandlerMock, 153 | smsInviteCommandHandlerMock, 154 | phoneNumberServiceMock, 155 | userServiceMock, 156 | membershipServiceMock, 157 | contextMock 158 | ) 159 | } 160 | } 161 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/handler/MessageToSmsHandlerTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.mockk.* 5 | import kotlinx.coroutines.flow.flowOf 6 | import net.folivo.matrix.bot.config.MatrixBotProperties 7 | import net.folivo.matrix.bot.event.MessageContext 8 | import net.folivo.matrix.bot.room.MatrixRoom 9 | import net.folivo.matrix.bot.room.MatrixRoomService 10 | import net.folivo.matrix.bot.user.MatrixUser 11 | import net.folivo.matrix.bot.user.MatrixUserService 12 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 13 | import net.folivo.matrix.bridge.sms.mapping.MatrixSmsMapping 14 | import net.folivo.matrix.bridge.sms.mapping.MatrixSmsMappingService 15 | import net.folivo.matrix.bridge.sms.provider.SmsProvider 16 | import net.folivo.matrix.core.model.MatrixId.* 17 | 18 | class MessageToSmsHandlerTest : DescribeSpec(testBody()) 19 | 20 | private fun testBody(): DescribeSpec.() -> Unit { 21 | return { 22 | val botUserId = UserId("bot", "server") 23 | val botPropertiesMock: MatrixBotProperties = mockk() 24 | val smsBridgePropertiesMock: SmsBridgeProperties = mockk { 25 | every { templates.outgoingMessageToken }.returns(" with {token}") 26 | every { templates.outgoingMessage }.returns("{sender} wrote {body}") 27 | every { templates.outgoingMessageFromBot }.returns("bot wrote {body}") 28 | every { templates.sendSmsError }.returns("error: {error}") 29 | every { templates.sendSmsIncompatibleMessage }.returns("incompatible") 30 | every { allowMappingWithoutToken }.returns(false) 31 | } 32 | val smsProviderMock: SmsProvider = mockk(relaxed = true) 33 | val roomServiceMock: MatrixRoomService = mockk() 34 | val userServiceMock: MatrixUserService = mockk() 35 | val mappingServiceMock: MatrixSmsMappingService = mockk() 36 | 37 | val cut = MessageToSmsHandler( 38 | botPropertiesMock, 39 | smsBridgePropertiesMock, 40 | smsProviderMock, 41 | roomServiceMock, 42 | userServiceMock, 43 | mappingServiceMock 44 | ) 45 | 46 | val contextMock: MessageContext = mockk() 47 | val roomId = RoomId("room", "server") 48 | val senderId = UserId("sender", "server") 49 | 50 | beforeTest { 51 | every { botPropertiesMock.botUserId }.returns(botUserId) 52 | coEvery { contextMock.answer(any(), any()) }.returns(EventId("event", "server")) 53 | } 54 | 55 | describe(MessageToSmsHandler::handleMessage.name) { 56 | val userId1 = UserId("sms_11111", "server") 57 | val userId2 = UserId("sms_22222", "server") 58 | beforeTest { 59 | coEvery { userServiceMock.getUsersByRoom(roomId) }.returns( 60 | flowOf( 61 | MatrixUser(botUserId, true), 62 | MatrixUser(senderId, false), 63 | MatrixUser(userId1, true), 64 | MatrixUser(userId2, true) 65 | ) 66 | ) 67 | coEvery { mappingServiceMock.getOrCreateMapping(userId1, roomId) } 68 | .returns(MatrixSmsMapping("memId", 2)) 69 | coEvery { mappingServiceMock.getOrCreateMapping(userId2, roomId) } 70 | .returns(MatrixSmsMapping("memId", 3)) 71 | } 72 | describe("mapping without token is allowed") { 73 | beforeTest { 74 | every { smsBridgePropertiesMock.allowMappingWithoutToken }.returns(true) 75 | } 76 | it("should ignore token when room is managed") { 77 | coEvery { roomServiceMock.getOrCreateRoom(roomId) } 78 | .returns(MatrixRoom(roomId, true)) 79 | cut.handleMessage(roomId, "body", senderId, contextMock, true) 80 | coVerify { 81 | smsProviderMock.sendSms("+11111", "@sender:server wrote body") 82 | smsProviderMock.sendSms("+22222", "@sender:server wrote body") 83 | } 84 | } 85 | it("should ignore token when room is the only room") { 86 | coEvery { roomServiceMock.getOrCreateRoom(roomId) } 87 | .returns(MatrixRoom(roomId, false)) 88 | coEvery { roomServiceMock.getRoomsByMembers(any()) } 89 | .returns(flowOf(mockk())) 90 | cut.handleMessage(roomId, "body", senderId, contextMock, true) 91 | coVerifyAll { 92 | smsProviderMock.sendSms("+11111", "@sender:server wrote body") 93 | smsProviderMock.sendSms("+22222", "@sender:server wrote body") 94 | } 95 | } 96 | it("should not ignore token when room is not managed and not the only room") { 97 | coEvery { roomServiceMock.getOrCreateRoom(roomId) } 98 | .returns(MatrixRoom(roomId, false)) 99 | coEvery { roomServiceMock.getRoomsByMembers(any()) } 100 | .returnsMany(flowOf(mockk(), mockk()), flowOf(mockk())) 101 | coEvery { mappingServiceMock.getOrCreateMapping(any(), roomId).mappingToken } 102 | .returns(2) 103 | cut.handleMessage(roomId, "body", senderId, contextMock, true) 104 | coVerifyAll { 105 | smsProviderMock.sendSms("+11111", "@sender:server wrote body with #2") 106 | smsProviderMock.sendSms("+22222", "@sender:server wrote body") 107 | } 108 | } 109 | } 110 | describe("mapping without token is not allowed") { 111 | beforeTest { 112 | every { smsBridgePropertiesMock.allowMappingWithoutToken }.returns(false) 113 | coEvery { roomServiceMock.getOrCreateRoom(roomId) } 114 | .returns(MatrixRoom(roomId, false)) 115 | coEvery { roomServiceMock.getRoomsByMembers(any()) } 116 | .returns(flowOf(mockk(), mockk())) 117 | coEvery { mappingServiceMock.getOrCreateMapping(any(), roomId).mappingToken } 118 | .returns(2) 119 | } 120 | it("should send sms") { 121 | cut.handleMessage(roomId, "body", senderId, contextMock, true) 122 | coVerifyAll { 123 | smsProviderMock.sendSms("+11111", "@sender:server wrote body with #2") 124 | smsProviderMock.sendSms("+22222", "@sender:server wrote body with #2") 125 | } 126 | } 127 | it("should not send sms back to sender (no loop)") { 128 | cut.handleMessage(roomId, "body", userId1, contextMock, true) 129 | coVerifyAll { 130 | smsProviderMock.sendSms("+22222", "@sms_11111:server wrote body with #2") 131 | } 132 | } 133 | it("should use other string with bot user") { 134 | cut.handleMessage(roomId, "body", botUserId, contextMock, true) 135 | coVerify { 136 | smsProviderMock.sendSms("+11111", "bot wrote body with #2") 137 | smsProviderMock.sendSms("+22222", "bot wrote body with #2") 138 | } 139 | } 140 | } 141 | describe("should answer with error message") { 142 | beforeTest { 143 | every { smsBridgePropertiesMock.allowMappingWithoutToken }.returns(true) 144 | coEvery { roomServiceMock.getOrCreateRoom(roomId) } 145 | .returns(MatrixRoom(roomId, true)) 146 | } 147 | it("when send sms fails") { 148 | coEvery { smsProviderMock.sendSms("+11111", any()) }.throws(RuntimeException("reason")) 149 | cut.handleMessage(roomId, "body", senderId, contextMock, true) 150 | coVerify { 151 | contextMock.answer("error: reason", asUserId = userId1) 152 | } 153 | } 154 | it("when wrong message type") { 155 | cut.handleMessage(roomId, "body", senderId, contextMock, false) 156 | coVerify { 157 | smsProviderMock wasNot Called 158 | contextMock.answer("incompatible", asUserId = userId1) 159 | contextMock.answer("incompatible", asUserId = userId2) 160 | } 161 | } 162 | } 163 | } 164 | afterTest { 165 | clearMocks( 166 | roomServiceMock, 167 | userServiceMock, 168 | smsProviderMock, 169 | mappingServiceMock, 170 | contextMock 171 | ) 172 | } 173 | } 174 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/handler/PhoneNumberServiceTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.google.i18n.phonenumbers.NumberParseException 4 | import io.kotest.matchers.booleans.shouldBeFalse 5 | import io.kotest.matchers.booleans.shouldBeTrue 6 | import io.mockk.every 7 | import io.mockk.impl.annotations.InjectMockKs 8 | import io.mockk.impl.annotations.MockK 9 | import io.mockk.junit5.MockKExtension 10 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 11 | import org.junit.jupiter.api.BeforeEach 12 | import org.junit.jupiter.api.Test 13 | import org.junit.jupiter.api.extension.ExtendWith 14 | import org.junit.jupiter.api.fail 15 | 16 | @ExtendWith(MockKExtension::class) 17 | class PhoneNumberServiceTest { 18 | 19 | @MockK 20 | lateinit var smsBridgePropertiesMock: SmsBridgeProperties 21 | 22 | @InjectMockKs 23 | lateinit var cut: PhoneNumberService 24 | 25 | @BeforeEach 26 | fun beforeEach() { 27 | every { smsBridgePropertiesMock.defaultRegion }.returns("DE") 28 | } 29 | 30 | @Test 31 | fun `should fail and echo when wrong telephone number`() { 32 | try { 33 | cut.parseToInternationalNumber("abc") 34 | cut.parseToInternationalNumber("123456789123456789") 35 | cut.parseToInternationalNumber("012345678 DINO") 36 | fail { "should have error" } 37 | } catch (error: NumberParseException) { 38 | 39 | } 40 | } 41 | 42 | @Test 43 | fun `should detect alphanumeric numbers`() { 44 | cut.isAlphanumeric("DINODINO").shouldBeTrue() 45 | cut.isAlphanumeric("DINODINODINO").shouldBeFalse() 46 | cut.isAlphanumeric("123-DINO").shouldBeFalse() 47 | cut.isAlphanumeric("+49123456789").shouldBeFalse() 48 | cut.isAlphanumeric("0123456789").shouldBeFalse() 49 | } 50 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/handler/ReceiveSmsServiceTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.google.i18n.phonenumbers.NumberParseException 4 | import com.google.i18n.phonenumbers.NumberParseException.ErrorType.NOT_A_NUMBER 5 | import io.kotest.core.spec.style.DescribeSpec 6 | import io.kotest.matchers.nulls.shouldBeNull 7 | import io.kotest.matchers.shouldBe 8 | import io.mockk.* 9 | import net.folivo.matrix.bot.config.MatrixBotProperties 10 | import net.folivo.matrix.bot.membership.MatrixMembershipService 11 | import net.folivo.matrix.bot.room.MatrixRoomService 12 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 13 | import net.folivo.matrix.bridge.sms.mapping.MatrixSmsMappingService 14 | import net.folivo.matrix.bridge.sms.message.MatrixMessageService 15 | import net.folivo.matrix.core.model.MatrixId.* 16 | import net.folivo.matrix.core.model.events.m.room.message.TextMessageEventContent 17 | import net.folivo.matrix.restclient.MatrixClient 18 | 19 | class ReceiveSmsServiceTest : DescribeSpec(testBody()) 20 | 21 | private fun testBody(): DescribeSpec.() -> Unit { 22 | return { 23 | val defaultRoomId = RoomId("default", "server") 24 | val matrixClientMock: MatrixClient = mockk() 25 | val mappingServiceMock: MatrixSmsMappingService = mockk() 26 | val messageServiceMock: MatrixMessageService = mockk(relaxed = true) 27 | val membershipServiceMock: MatrixMembershipService = mockk() 28 | val roomServiceMock: MatrixRoomService = mockk(relaxed = true) 29 | val phoneNumberServiceMock: PhoneNumberService = mockk() 30 | val matrixBotPropertiesMock: MatrixBotProperties = mockk() 31 | val smsBridgePropertiesMock: SmsBridgeProperties = mockk { 32 | every { templates.defaultRoomIncomingMessage } returns "{sender} wrote {body}" 33 | every { templates.defaultRoomIncomingMessageWithSingleMode } returns "{sender} wrote in {roomAlias}" 34 | every { templates.answerInvalidTokenWithDefaultRoom } returns "invalid token with default room" 35 | every { templates.answerInvalidTokenWithoutDefaultRoom } returns "invalid token without default room" 36 | } 37 | val cut = ReceiveSmsService( 38 | matrixClientMock, 39 | mappingServiceMock, 40 | messageServiceMock, 41 | membershipServiceMock, 42 | roomServiceMock, 43 | phoneNumberServiceMock, 44 | matrixBotPropertiesMock, 45 | smsBridgePropertiesMock 46 | ) 47 | 48 | beforeTest { 49 | every { smsBridgePropertiesMock.defaultRoomId } returns defaultRoomId 50 | every { matrixBotPropertiesMock.serverName } returns "server" 51 | coEvery { matrixClientMock.roomsApi.sendRoomEvent(any(), any(), any(), any(), any()) } 52 | .returns(EventId("event", "server")) 53 | coEvery { messageServiceMock.sendRoomMessage(any(), any()) } just Runs 54 | } 55 | 56 | describe(ReceiveSmsService::receiveSms.name) { 57 | describe("sender is alphanumeric") { 58 | it("should send message to default room") { 59 | coEvery { phoneNumberServiceMock.isAlphanumeric(any()) } 60 | .returns(true) 61 | cut.receiveSms("some body", "DINODINO") 62 | coVerify { 63 | matrixClientMock.roomsApi.sendRoomEvent( 64 | defaultRoomId, 65 | match { it.body == "DINODINO wrote some body" }, 66 | txnId = any(), 67 | asUserId = null 68 | ) 69 | } 70 | } 71 | } 72 | describe("sender is invalid") { 73 | it("should send message to default room") { 74 | coEvery { phoneNumberServiceMock.isAlphanumeric(any()) } 75 | .returns(false) 76 | coEvery { phoneNumberServiceMock.parseToInternationalNumber(any()) } 77 | .throws(NumberParseException(NOT_A_NUMBER, "not a valid number")) 78 | cut.receiveSms("some body", "+4912") 79 | coVerify { 80 | matrixClientMock.roomsApi.sendRoomEvent( 81 | defaultRoomId, 82 | match { it.body == "+4912 wrote some body" }, 83 | txnId = any(), 84 | asUserId = null 85 | ) 86 | } 87 | } 88 | } 89 | describe("sender is valid") { 90 | val roomId = RoomId("room", "server") 91 | beforeTest { 92 | coEvery { phoneNumberServiceMock.parseToInternationalNumber("+111111") } 93 | .returns("+111111") 94 | coEvery { phoneNumberServiceMock.isAlphanumeric(any()) } 95 | .returns(false) 96 | } 97 | describe("mapping token was valid") { 98 | beforeTest { coEvery { mappingServiceMock.getRoomId(any(), any()) }.returns(roomId) } 99 | it("should forward message to room") { 100 | cut.receiveSms("message #3", "+111111").shouldBeNull() 101 | coVerify { 102 | matrixClientMock.roomsApi.sendRoomEvent( 103 | roomId, 104 | match { it.body == "message" }, 105 | txnId = any(), 106 | asUserId = UserId("sms_111111", "server") 107 | ) 108 | } 109 | } 110 | } 111 | describe("mapping token was not valid") { 112 | beforeTest { 113 | coEvery { mappingServiceMock.getRoomId(any(), any()) }.returns(null) 114 | } 115 | describe("single mode enabled") { 116 | beforeTest { 117 | coEvery { smsBridgePropertiesMock.singleModeEnabled }.returns(true) 118 | coEvery { roomServiceMock.getRoomAlias(any())?.roomId }.returns(roomId) 119 | } 120 | describe("room alias not in database") { 121 | val roomAliasId = RoomAliasId("sms_111111", "server") 122 | beforeTest { 123 | coEvery { membershipServiceMock.hasRoomOnlyManagedUsersLeft(any()) }.returns(false) 124 | coEvery { roomServiceMock.getRoomAlias(roomAliasId)?.roomId } 125 | .returns(null) 126 | coEvery { matrixClientMock.roomsApi.getRoomAlias(roomAliasId).roomId }.returns(roomId) 127 | } 128 | it("should try create alias by using client-server-api") { 129 | cut.receiveSms("body #123", "+111111").shouldBeNull() 130 | coVerify { matrixClientMock.roomsApi.getRoomAlias(roomAliasId) } 131 | } 132 | } 133 | describe("not only managed users in room") { 134 | beforeTest { 135 | coEvery { membershipServiceMock.hasRoomOnlyManagedUsersLeft(any()) }.returns( 136 | false 137 | ) 138 | } 139 | it("should send message to alias room") { 140 | cut.receiveSms("body #123", "+111111").shouldBeNull() 141 | coVerify { 142 | messageServiceMock.sendRoomMessage( 143 | match { 144 | it.roomId == roomId 145 | && it.body == "body" 146 | && it.isNotice == false 147 | && it.asUserId == UserId("sms_111111", "server") 148 | } 149 | ) 150 | } 151 | } 152 | } 153 | describe("only managed users in room") { 154 | beforeTest { 155 | coEvery { membershipServiceMock.hasRoomOnlyManagedUsersLeft(any()) }.returns( 156 | true 157 | ) 158 | } 159 | describe("default room given") { 160 | beforeTest { 161 | coEvery { smsBridgePropertiesMock.defaultRoomId } 162 | .returns(RoomId("default", "room")) 163 | } 164 | it("should send notification to default room") { 165 | cut.receiveSms("body #123", "+111111").shouldBe(null) 166 | coVerify { 167 | matrixClientMock.roomsApi.sendRoomEvent( 168 | RoomId("default", "room"), 169 | match { it.body == "+111111 wrote in #sms_111111:server" }, 170 | txnId = any() 171 | ) 172 | } 173 | } 174 | } 175 | describe("default room not given") { 176 | beforeTest { coEvery { smsBridgePropertiesMock.defaultRoomId }.returns(null) } 177 | it("should answer and do nothing") { 178 | cut.receiveSms("message #3", "+111111") 179 | .shouldBe("invalid token without default room") 180 | coVerify(exactly = 1) { 181 | messageServiceMock.sendRoomMessage(any(), any()) 182 | } 183 | } 184 | } 185 | } 186 | } 187 | describe("single mode not enabled") { 188 | beforeTest { coEvery { smsBridgePropertiesMock.singleModeEnabled }.returns(false) } 189 | describe("default room given") { 190 | beforeTest { 191 | coEvery { smsBridgePropertiesMock.defaultRoomId }.returns(RoomId("default", "room")) 192 | } 193 | it("should forward message to default room") { 194 | cut.receiveSms("body #123", "+111111").shouldBe("invalid token with default room") 195 | coVerify { 196 | matrixClientMock.roomsApi.sendRoomEvent( 197 | RoomId("default", "room"), 198 | match { it.body == "+111111 wrote body" }, 199 | txnId = any() 200 | ) 201 | } 202 | } 203 | } 204 | describe("default room not given") { 205 | beforeTest { coEvery { smsBridgePropertiesMock.defaultRoomId }.returns(null) } 206 | it("should answer and do nothing") { 207 | cut.receiveSms("message #3", "+111111").shouldBe("invalid token without default room") 208 | coVerify { 209 | matrixClientMock wasNot Called 210 | } 211 | } 212 | } 213 | } 214 | } 215 | } 216 | } 217 | afterTest { 218 | clearMocks( 219 | matrixClientMock, 220 | mappingServiceMock, 221 | messageServiceMock, 222 | membershipServiceMock, 223 | phoneNumberServiceMock, 224 | roomServiceMock, 225 | smsBridgePropertiesMock 226 | ) 227 | } 228 | } 229 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/handler/SmsAbortCommandHandlerTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockk.* 6 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 7 | import net.folivo.matrix.bridge.sms.message.MatrixMessageService 8 | import net.folivo.matrix.core.model.MatrixId.RoomId 9 | 10 | class SmsAbortCommandHandlerTest : DescribeSpec(testBody()) 11 | 12 | private fun testBody(): DescribeSpec.() -> Unit { 13 | return { 14 | val messageServiceMock: MatrixMessageService = mockk() 15 | val smsBridgePropertiesMock: SmsBridgeProperties = mockk { 16 | every { templates.botSmsAbortSuccess }.returns("success") 17 | every { templates.botSmsAbortError }.returns("error:{error}") 18 | } 19 | val cut = SmsAbortCommandHandler(messageServiceMock, smsBridgePropertiesMock) 20 | 21 | val roomId = RoomId("room", "server") 22 | 23 | describe(SmsAbortCommandHandler::handleCommand.name) { 24 | it("should delete messages") { 25 | coEvery { messageServiceMock.deleteByRoomId(roomId) } just Runs 26 | cut.handleCommand(roomId).shouldBe("success") 27 | coVerify { messageServiceMock.deleteByRoomId(roomId) } 28 | } 29 | it("should catch error") { 30 | coEvery { messageServiceMock.deleteByRoomId(roomId) }.throws(RuntimeException("meteor")) 31 | cut.handleCommand(roomId).shouldBe("error:meteor") 32 | } 33 | } 34 | 35 | afterTest { clearMocks(messageServiceMock) } 36 | } 37 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/handler/SmsAbortCommandTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.github.ajalt.clikt.core.context 4 | import com.github.ajalt.clikt.output.CliktConsole 5 | import io.kotest.core.spec.style.DescribeSpec 6 | import io.mockk.clearMocks 7 | import io.mockk.coEvery 8 | import io.mockk.coVerify 9 | import io.mockk.mockk 10 | import net.folivo.matrix.core.model.MatrixId.RoomId 11 | 12 | class SmsAbortCommandTest : DescribeSpec(testBody()) 13 | 14 | private fun testBody(): DescribeSpec.() -> Unit { 15 | return { 16 | val roomId = RoomId("room", "server") 17 | val handlerMock: SmsAbortCommandHandler = mockk() 18 | val consoleMock: CliktConsole = mockk(relaxed = true) 19 | val cut = SmsAbortCommand(roomId, handlerMock) 20 | cut.context { console = consoleMock } 21 | 22 | describe("run command") { 23 | it("should run command") { 24 | coEvery { handlerMock.handleCommand(roomId) }.returns("answer") 25 | cut.parse(listOf()) 26 | coVerify { handlerMock.handleCommand(roomId) } 27 | coVerify { consoleMock.print("answer", false) } 28 | } 29 | } 30 | 31 | afterTest { clearMocks(consoleMock) } 32 | } 33 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/handler/SmsBotConsoleTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import io.mockk.coEvery 4 | import io.mockk.coVerify 5 | import io.mockk.impl.annotations.MockK 6 | import io.mockk.junit5.MockKExtension 7 | import net.folivo.matrix.bot.event.MessageContext 8 | import net.folivo.matrix.core.model.MatrixId.EventId 9 | import org.assertj.core.api.Assertions.assertThat 10 | import org.junit.jupiter.api.Test 11 | import org.junit.jupiter.api.extension.ExtendWith 12 | 13 | @ExtendWith(MockKExtension::class) 14 | class SmsBotConsoleTest { 15 | 16 | @MockK 17 | lateinit var contextMock: MessageContext 18 | 19 | @Test 20 | fun `should send text to matrix room`() { 21 | coEvery { contextMock.answer(any(), any()) }.returns(EventId("event", "server")) 22 | val cut = SmsBotConsole(contextMock) 23 | cut.print("some Text", true) 24 | coVerify { contextMock.answer("some Text") } 25 | } 26 | 27 | @Test 28 | fun `line separator should be empty`() { 29 | val cut = SmsBotConsole(contextMock) 30 | assertThat(cut.lineSeparator).isEmpty() 31 | } 32 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/handler/SmsInviteCommandHandlerTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockk.* 6 | import net.folivo.matrix.bot.room.MatrixRoomService 7 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 8 | import net.folivo.matrix.core.model.MatrixId.* 9 | import net.folivo.matrix.restclient.MatrixClient 10 | 11 | class SmsInviteCommandHelperTest : DescribeSpec(testBody()) 12 | 13 | private fun testBody(): DescribeSpec.() -> Unit { 14 | return { 15 | val roomServiceMock: MatrixRoomService = mockk() 16 | val matrixClientMock: MatrixClient = mockk() 17 | val smsBridgePropertiesMock: SmsBridgeProperties = mockk { 18 | every { templates.botSmsInviteSuccess }.returns("{sender} invited to {roomAlias}") 19 | every { templates.botSmsInviteError }.returns("{sender} not invited to {roomAlias}:{error}") 20 | } 21 | val cut = SmsInviteCommandHandler(roomServiceMock, matrixClientMock, smsBridgePropertiesMock) 22 | 23 | val senderId = UserId("sender", "server") 24 | val aliasId = RoomAliasId("alias", "server") 25 | val roomId = RoomId("room", "server") 26 | 27 | describe("alias does exists in database") { 28 | beforeTest { coEvery { roomServiceMock.getRoomAlias(aliasId)?.roomId }.returns(roomId) } 29 | describe("invite is successful") { 30 | beforeTest { 31 | coEvery { matrixClientMock.roomsApi.inviteUser(any(), any()) } just Runs 32 | } 33 | it("should invite and answer") { 34 | cut.handleCommand(senderId, aliasId).shouldBe("@sender:server invited to #alias:server") 35 | coVerify { matrixClientMock.roomsApi.inviteUser(roomId, senderId) } 36 | } 37 | } 38 | describe("invite is not successful") { 39 | beforeTest { 40 | coEvery { matrixClientMock.roomsApi.inviteUser(roomId, senderId) }.throws(RuntimeException("error")) 41 | } 42 | it("should invite and answer") { 43 | cut.handleCommand(senderId, aliasId).shouldBe("@sender:server not invited to #alias:server:error") 44 | } 45 | } 46 | } 47 | describe("alias does not exists in database") { 48 | beforeTest { 49 | coEvery { roomServiceMock.getRoomAlias(any()) }.returns(null) 50 | coEvery { matrixClientMock.roomsApi.getRoomAlias(aliasId).roomId }.returns(roomId) 51 | coEvery { matrixClientMock.roomsApi.inviteUser(any(), any()) } just Runs 52 | } 53 | it("should invite with room id from server") { 54 | cut.handleCommand(senderId, aliasId) 55 | coVerify { matrixClientMock.roomsApi.inviteUser(roomId, senderId) } 56 | } 57 | } 58 | 59 | afterTest { clearMocks(roomServiceMock, matrixClientMock, smsBridgePropertiesMock) } 60 | 61 | } 62 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/handler/SmsInviteCommandTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.github.ajalt.clikt.core.BadParameterValue 4 | import com.github.ajalt.clikt.core.MissingArgument 5 | import com.github.ajalt.clikt.core.context 6 | import com.github.ajalt.clikt.output.CliktConsole 7 | import io.kotest.assertions.throwables.shouldThrow 8 | import io.kotest.core.spec.style.DescribeSpec 9 | import io.mockk.clearMocks 10 | import io.mockk.coEvery 11 | import io.mockk.coVerify 12 | import io.mockk.mockk 13 | import net.folivo.matrix.core.model.MatrixId.RoomAliasId 14 | import net.folivo.matrix.core.model.MatrixId.UserId 15 | 16 | class SmsInviteCommandTest : DescribeSpec(testBody()) 17 | 18 | private fun testBody(): DescribeSpec.() -> Unit { 19 | return { 20 | val sender = UserId("sender", "server") 21 | val roomAliasId = RoomAliasId("alias", "server") 22 | val handlerMock: SmsInviteCommandHandler = mockk() 23 | val consoleMock: CliktConsole = mockk(relaxed = true) 24 | val cut = SmsInviteCommand(sender, handlerMock) 25 | cut.context { console = consoleMock } 26 | 27 | describe("alias was given") { 28 | it("should run") { 29 | coEvery { handlerMock.handleCommand(sender, roomAliasId) }.returns("answer") 30 | cut.parse(listOf("#alias:server")) 31 | coVerify { handlerMock.handleCommand(sender, roomAliasId) } 32 | coVerify { consoleMock.print("answer", false) } 33 | } 34 | } 35 | describe("alias was not given") { 36 | it("should throw missing argument") { 37 | shouldThrow { 38 | cut.parse(listOf()) 39 | } 40 | } 41 | } 42 | describe("alias was no alias") { 43 | it("should throw bad parameter") { 44 | shouldThrow { 45 | cut.parse(listOf("!noAlias:server")) 46 | } 47 | } 48 | } 49 | 50 | afterTest { clearMocks(consoleMock) } 51 | } 52 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/handler/SmsMessageHandlerTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.mockk.* 5 | import net.folivo.matrix.bot.config.MatrixBotProperties 6 | import net.folivo.matrix.bot.event.MessageContext 7 | import net.folivo.matrix.bot.membership.MatrixMembershipService 8 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 9 | import net.folivo.matrix.core.model.MatrixId.RoomId 10 | import net.folivo.matrix.core.model.MatrixId.UserId 11 | import net.folivo.matrix.core.model.events.m.room.message.NoticeMessageEventContent 12 | import net.folivo.matrix.core.model.events.m.room.message.TextMessageEventContent 13 | 14 | class SmsMessageHandlerTest : DescribeSpec(testBody()) 15 | 16 | private fun testBody(): DescribeSpec.() -> Unit { 17 | return { 18 | val messageToSmsHandlerMock: MessageToSmsHandler = mockk() 19 | val messageToBotHandlerMock: MessageToBotHandler = mockk() 20 | val membershipServiceMock: MatrixMembershipService = mockk() 21 | val botPropertiesMock: MatrixBotProperties = mockk { 22 | every { botUserId } returns UserId("bot", "server") 23 | } 24 | val smsBridgePropertiesMock: SmsBridgeProperties = mockk { 25 | every { defaultRoomId } returns RoomId("default", "server") 26 | } 27 | 28 | val cut = SmsMessageHandler( 29 | messageToSmsHandlerMock, 30 | messageToBotHandlerMock, 31 | membershipServiceMock, 32 | botPropertiesMock, 33 | smsBridgePropertiesMock 34 | ) 35 | 36 | val senderId = UserId("sender", "server") 37 | val roomId = RoomId("room", "server") 38 | val contextMock: MessageContext = mockk { 39 | every { originalEvent.sender }.returns(senderId) 40 | } 41 | 42 | beforeTest { 43 | coEvery { messageToSmsHandlerMock.handleMessage(any(), any(), any(), any(), any()) } just Runs 44 | } 45 | 46 | describe(SmsMessageHandler::handleMessage.name) { 47 | describe("room is default room") { 48 | beforeTest { every { contextMock.roomId }.returns(RoomId("default", "server")) } 49 | it("should ignore message") { 50 | cut.handleMessage(TextMessageEventContent("body"), contextMock) 51 | coVerify { 52 | messageToBotHandlerMock wasNot Called 53 | messageToSmsHandlerMock wasNot Called 54 | } 55 | } 56 | } 57 | describe("message is notice") { 58 | beforeTest { every { contextMock.roomId }.returns(roomId) } 59 | it("should ignore message") { 60 | cut.handleMessage(NoticeMessageEventContent("notice"), contextMock) 61 | coVerify { 62 | messageToBotHandlerMock wasNot Called 63 | messageToSmsHandlerMock wasNot Called 64 | } 65 | } 66 | } 67 | describe("massage is handleable") { 68 | beforeTest { every { contextMock.roomId }.returns(roomId) } 69 | describe("bot is member of room") { 70 | beforeTest { 71 | coEvery { membershipServiceMock.doesRoomContainsMembers(roomId, any()) }.returns(true) 72 | } 73 | it("should delegate to bot handler") { 74 | coEvery { messageToBotHandlerMock.handleMessage(any(), any(), any(), any()) }.returns(true) 75 | cut.handleMessage(TextMessageEventContent("body"), contextMock) 76 | coVerify { 77 | messageToBotHandlerMock.handleMessage(roomId, "body", senderId, contextMock) 78 | messageToSmsHandlerMock wasNot Called 79 | } 80 | } 81 | it("should delegate to sms handler when not handled by bot handler") { 82 | coEvery { messageToBotHandlerMock.handleMessage(any(), any(), any(), any()) }.returns(false) 83 | coEvery { messageToSmsHandlerMock.handleMessage(any(), any(), any(), any(), any()) } just Runs 84 | cut.handleMessage(TextMessageEventContent("body"), contextMock) 85 | coVerify { 86 | messageToBotHandlerMock.handleMessage(roomId, "body", senderId, contextMock) 87 | messageToSmsHandlerMock.handleMessage(roomId, "body", senderId, contextMock, true) 88 | } 89 | } 90 | } 91 | describe("bot is not member of room") { 92 | beforeTest { 93 | coEvery { membershipServiceMock.doesRoomContainsMembers(roomId, any()) }.returns(false) 94 | } 95 | it("should delegate to sms handler") { 96 | coEvery { messageToSmsHandlerMock.handleMessage(any(), any(), any(), any(), any()) } just Runs 97 | cut.handleMessage(TextMessageEventContent("body"), contextMock) 98 | coVerify { 99 | messageToBotHandlerMock wasNot Called 100 | messageToSmsHandlerMock.handleMessage(roomId, "body", senderId, contextMock, true) 101 | } 102 | } 103 | it("should detect if text message") { 104 | coEvery { messageToSmsHandlerMock.handleMessage(any(), any(), any(), any(), any()) } just Runs 105 | cut.handleMessage(mockk { every { body }.returns("body") }, contextMock) 106 | coVerify { 107 | messageToBotHandlerMock wasNot Called 108 | messageToSmsHandlerMock.handleMessage(roomId, "body", senderId, contextMock, false) 109 | } 110 | } 111 | } 112 | } 113 | } 114 | 115 | afterTest { 116 | clearMocks( 117 | messageToSmsHandlerMock, 118 | messageToBotHandlerMock, 119 | membershipServiceMock 120 | ) 121 | } 122 | } 123 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/handler/SmsSendCommandTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.handler 2 | 3 | import com.github.ajalt.clikt.core.context 4 | import com.github.ajalt.clikt.output.CliktConsole 5 | import com.google.i18n.phonenumbers.NumberParseException 6 | import com.google.i18n.phonenumbers.NumberParseException.ErrorType.NOT_A_NUMBER 7 | import io.mockk.coEvery 8 | import io.mockk.coVerify 9 | import io.mockk.coVerifyAll 10 | import io.mockk.every 11 | import io.mockk.impl.annotations.MockK 12 | import io.mockk.junit5.MockKExtension 13 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 14 | import net.folivo.matrix.bridge.sms.handler.SmsSendCommand.RoomCreationMode.ALWAYS 15 | import net.folivo.matrix.bridge.sms.handler.SmsSendCommand.RoomCreationMode.AUTO 16 | import net.folivo.matrix.core.model.MatrixId.UserId 17 | import org.junit.jupiter.api.BeforeEach 18 | import org.junit.jupiter.api.Test 19 | import org.junit.jupiter.api.extension.ExtendWith 20 | import java.time.LocalDateTime 21 | 22 | @ExtendWith(MockKExtension::class) 23 | class SmsSendCommandTest { 24 | @MockK 25 | lateinit var handler: SmsSendCommandHandler 26 | 27 | @MockK 28 | lateinit var phoneNumberServiceMock: PhoneNumberService 29 | 30 | @MockK 31 | lateinit var smsBridgePropertiesMock: SmsBridgeProperties 32 | 33 | private lateinit var cut: SmsSendCommand 34 | 35 | @MockK(relaxed = true) 36 | lateinit var consoleMock: CliktConsole 37 | 38 | private val senderId = UserId("sender", "server") 39 | 40 | @BeforeEach 41 | fun beforeEach() { 42 | coEvery { handler.handleCommand(any(), any(), any(), any(), any(), any(), any()) }.returns("answer") 43 | every { smsBridgePropertiesMock.templates.botSmsSendInvalidTelephoneNumber }.returns("invalid") 44 | every { phoneNumberServiceMock.parseToInternationalNumber("017331111111") }.returns("+4917331111111") 45 | every { phoneNumberServiceMock.parseToInternationalNumber("017332222222") }.returns("+4917332222222") 46 | cut = SmsSendCommand(senderId, handler, phoneNumberServiceMock, smsBridgePropertiesMock) 47 | cut.context { console = consoleMock } 48 | } 49 | 50 | @Test 51 | fun `should send message to muliple numbers`() { 52 | cut.parse(listOf("some text", "-t", "017331111111", "-t", "017332222222")) 53 | 54 | coVerifyAll { 55 | handler.handleCommand( 56 | body = "some text", 57 | senderId = senderId, 58 | receiverNumbers = setOf("+4917331111111"), 59 | inviteUserIds = setOf(), 60 | roomName = null, 61 | roomCreationMode = AUTO, 62 | sendAfterLocal = null 63 | ) 64 | handler.handleCommand( 65 | body = "some text", 66 | senderId = senderId, 67 | receiverNumbers = setOf("+4917332222222"), 68 | inviteUserIds = setOf(), 69 | roomName = null, 70 | roomCreationMode = AUTO, 71 | sendAfterLocal = null 72 | ) 73 | } 74 | } 75 | 76 | @Test 77 | fun `should send message in future`() { 78 | cut.parse(listOf("some text", "-t", "017331111111", "-a", "1955-11-09T12:00")) 79 | 80 | coVerifyAll { 81 | handler.handleCommand( 82 | body = "some text", 83 | senderId = senderId, 84 | receiverNumbers = setOf("+4917331111111"), 85 | inviteUserIds = setOf(), 86 | roomName = null, 87 | roomCreationMode = AUTO, 88 | sendAfterLocal = LocalDateTime.of(1955, 11, 9, 12, 0) 89 | ) 90 | } 91 | } 92 | 93 | @Test 94 | fun `should send message to group and use name`() { 95 | cut.parse(listOf("some text", "-t", "017331111111", "-t", "017332222222", "-n", "some name", "-g")) 96 | 97 | coVerify { 98 | handler.handleCommand( 99 | body = "some text", 100 | senderId = senderId, 101 | receiverNumbers = setOf("+4917331111111", "+4917332222222"), 102 | inviteUserIds = setOf(), 103 | roomName = "some name", 104 | roomCreationMode = AUTO, 105 | sendAfterLocal = null 106 | ) 107 | } 108 | } 109 | 110 | @Test 111 | fun `should send message and create room`() { 112 | cut.parse(listOf("some text", "-t", "017331111111", "-m", "always")) 113 | 114 | coVerify { 115 | handler.handleCommand( 116 | body = "some text", 117 | senderId = senderId, 118 | receiverNumbers = setOf("+4917331111111"), 119 | inviteUserIds = setOf(), 120 | roomName = null, 121 | roomCreationMode = ALWAYS, 122 | sendAfterLocal = null 123 | ) 124 | } 125 | } 126 | 127 | @Test 128 | fun `should invite users`() { 129 | cut.parse(listOf("some text", "-t", "017331111111", "-i", "@test1:test", "-i", "@test2:test")) 130 | 131 | coVerify { 132 | handler.handleCommand( 133 | body = "some text", 134 | senderId = senderId, 135 | receiverNumbers = setOf("+4917331111111"), 136 | inviteUserIds = setOf(UserId("test1", "test"), UserId("test2", "test")), 137 | roomName = null, 138 | roomCreationMode = AUTO, 139 | sendAfterLocal = null 140 | ) 141 | } 142 | } 143 | 144 | @Test 145 | fun `should echo answer from service`() { 146 | cut.parse(listOf("some text", "-t", "017331111111")) 147 | cut.parse(listOf("some text", "-t", "017331111111", "-g")) 148 | 149 | coVerify(exactly = 2) { consoleMock.print("answer", any()) } 150 | } 151 | 152 | @Test 153 | fun `should fail and echo when wrong telephone number`() { 154 | every { phoneNumberServiceMock.parseToInternationalNumber(any()) }.throws( 155 | NumberParseException( 156 | NOT_A_NUMBER, 157 | "not a valid number" 158 | ) 159 | ) 160 | cut.parse(listOf("some text", "-t", "012345678 DINO")) 161 | 162 | coVerify { consoleMock.print("invalid", any()) } 163 | } 164 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/mapping/MatrixSmsMappingRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.mapping 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.kotest.matchers.collections.shouldBeEmpty 5 | import io.kotest.matchers.collections.shouldContainInOrder 6 | import io.kotest.matchers.nulls.shouldBeNull 7 | import io.kotest.matchers.shouldBe 8 | import kotlinx.coroutines.flow.toList 9 | import kotlinx.coroutines.reactive.awaitFirst 10 | import net.folivo.matrix.bot.config.MatrixBotDatabaseAutoconfiguration 11 | import net.folivo.matrix.bot.membership.MatrixMembership 12 | import net.folivo.matrix.bot.room.MatrixRoom 13 | import net.folivo.matrix.bot.user.MatrixUser 14 | import net.folivo.matrix.bridge.sms.SmsBridgeDatabaseConfiguration 15 | import net.folivo.matrix.core.model.MatrixId.RoomId 16 | import net.folivo.matrix.core.model.MatrixId.UserId 17 | import org.springframework.boot.autoconfigure.ImportAutoConfiguration 18 | import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest 19 | import org.springframework.data.r2dbc.core.R2dbcEntityTemplate 20 | import org.springframework.data.r2dbc.core.delete 21 | 22 | @DataR2dbcTest 23 | @ImportAutoConfiguration(value = [MatrixBotDatabaseAutoconfiguration::class, SmsBridgeDatabaseConfiguration::class]) 24 | class MatrixSmsMappingRepositoryTest( 25 | cut: MatrixSmsMappingRepository, 26 | db: R2dbcEntityTemplate 27 | ) : DescribeSpec(testBody(cut, db)) 28 | 29 | private fun testBody(cut: MatrixSmsMappingRepository, db: R2dbcEntityTemplate): DescribeSpec.() -> Unit { 30 | return { 31 | val user1 = UserId("user1", "server") 32 | val user2 = UserId("user2", "server") 33 | val user3 = UserId("user3", "server") 34 | val room1 = RoomId("room1", "server") 35 | val room2 = RoomId("room2", "server") 36 | val room3 = RoomId("room3", "server") 37 | 38 | var map1: MatrixSmsMapping? = null 39 | var map3: MatrixSmsMapping? = null 40 | var map4: MatrixSmsMapping? = null 41 | var map5: MatrixSmsMapping? = null 42 | 43 | beforeSpec { 44 | db.insert(MatrixUser(user1)).awaitFirst() 45 | db.insert(MatrixUser(user2)).awaitFirst() 46 | db.insert(MatrixUser(user3)).awaitFirst() 47 | db.insert(MatrixRoom(room1)).awaitFirst() 48 | db.insert(MatrixRoom(room2)).awaitFirst() 49 | db.insert(MatrixRoom(room3)).awaitFirst() 50 | val mem1 = db.insert(MatrixMembership(user1, room1)).awaitFirst().id 51 | val mem3 = db.insert(MatrixMembership(user1, room2)).awaitFirst().id 52 | val mem4 = db.insert(MatrixMembership(user1, room3)).awaitFirst().id 53 | val mem5 = db.insert(MatrixMembership(user2, room3)).awaitFirst().id 54 | map1 = db.insert(MatrixSmsMapping(mem1, 5)).awaitFirst() 55 | map3 = db.insert(MatrixSmsMapping(mem3, 9)).awaitFirst() 56 | map4 = db.insert(MatrixSmsMapping(mem4, 1)).awaitFirst() 57 | map5 = db.insert(MatrixSmsMapping(mem5, 1)).awaitFirst() 58 | } 59 | 60 | describe(MatrixSmsMappingRepository::findByUserIdSortByMappingTokenDesc.name) { 61 | it("should find and sort mapping tokens") { 62 | cut.findByUserIdSortByMappingTokenDesc(user1).toList() 63 | .shouldContainInOrder(map3, map1, map4) 64 | } 65 | it("should not find and sort mapping tokens") { 66 | cut.findByUserIdSortByMappingTokenDesc(user3).toList() 67 | .shouldBeEmpty() 68 | } 69 | } 70 | 71 | describe(MatrixSmsMappingRepository::findByUserIdAndMappingToken.name) { 72 | it("should find by user id and mapping token") { 73 | cut.findByUserIdAndMappingToken(user2, 1) 74 | .shouldBe(map5) 75 | } 76 | it("should not find by user id and mapping token") { 77 | cut.findByUserIdAndMappingToken(user2, 5) 78 | .shouldBeNull() 79 | cut.findByUserIdAndMappingToken(user3, 1) 80 | .shouldBeNull() 81 | } 82 | } 83 | 84 | 85 | afterSpec { 86 | db.delete().all().awaitFirst() 87 | db.delete().all().awaitFirst() 88 | db.delete().all().awaitFirst() 89 | db.delete().all().awaitFirst() 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/mapping/MatrixSmsMappingServiceTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.mapping 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.kotest.core.spec.style.describeSpec 5 | import io.kotest.matchers.nulls.shouldBeNull 6 | import io.kotest.matchers.shouldBe 7 | import io.mockk.* 8 | import kotlinx.coroutines.flow.flowOf 9 | import net.folivo.matrix.bot.membership.MatrixMembership 10 | import net.folivo.matrix.bot.membership.MatrixMembershipService 11 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 12 | import net.folivo.matrix.core.model.MatrixId.RoomId 13 | import net.folivo.matrix.core.model.MatrixId.UserId 14 | 15 | class MatrixSmsMappingServiceTest : DescribeSpec(testBody()) 16 | 17 | private fun testBody(): DescribeSpec.() -> Unit { 18 | return { 19 | val mappingRepositoryMock: MatrixSmsMappingRepository = mockk() 20 | val membershipServiceMock: MatrixMembershipService = mockk() 21 | val smsBridgePropertiesMock: SmsBridgeProperties = mockk() 22 | 23 | val cut = MatrixSmsMappingService(mappingRepositoryMock, membershipServiceMock, smsBridgePropertiesMock) 24 | 25 | val userId = UserId("user", "server") 26 | val roomId = RoomId("room", "server") 27 | val membership = MatrixMembership(userId, roomId) 28 | val mapping = MatrixSmsMapping(membership.id, 2) 29 | 30 | describe(MatrixSmsMappingService::getOrCreateMapping.name) { 31 | beforeTest { 32 | coEvery { membershipServiceMock.getOrCreateMembership(userId, roomId) } 33 | .returns(membership) 34 | } 35 | describe("mapping in database") { 36 | beforeTest { coEvery { mappingRepositoryMock.findByMembershipId(membership.id) }.returns(mapping) } 37 | it("should return entity from database") { 38 | cut.getOrCreateMapping(userId, roomId).shouldBe(mapping) 39 | coVerify(exactly = 0) { mappingRepositoryMock.save(any()) } 40 | } 41 | } 42 | describe("mapping not in database") { 43 | beforeTest { coEvery { mappingRepositoryMock.findByMembershipId(membership.id) }.returns(null) } 44 | describe("no last mapping token found") { 45 | beforeTest { 46 | coEvery { mappingRepositoryMock.findByUserIdSortByMappingTokenDesc(userId) } 47 | .returns(flowOf()) 48 | } 49 | it("should create and save new entity and start with 1 as token") { 50 | val savedMapping = MatrixSmsMapping(membership.id, 1, 2) 51 | coEvery { mappingRepositoryMock.save(MatrixSmsMapping(membership.id, 1)) } 52 | .returns(savedMapping) 53 | cut.getOrCreateMapping(userId, roomId).shouldBe(savedMapping) 54 | } 55 | } 56 | describe("last mapping token found") { 57 | beforeTest { 58 | coEvery { mappingRepositoryMock.findByUserIdSortByMappingTokenDesc(userId) } 59 | .returns(flowOf(MatrixSmsMapping(membership.id, 14, 2))) 60 | } 61 | it("should create and save new entity and start with 1 as token") { 62 | val savedMapping = MatrixSmsMapping(membership.id, 15, 2) 63 | coEvery { mappingRepositoryMock.save(MatrixSmsMapping(membership.id, 15)) } 64 | .returns(savedMapping) 65 | cut.getOrCreateMapping(userId, roomId).shouldBe(savedMapping) 66 | } 67 | } 68 | } 69 | } 70 | describe(MatrixSmsMappingService::getRoomId.name) { 71 | val testFindSingleRoomIdMapping = { name: String -> 72 | describeSpec { 73 | describe("$name allow mapping without token") { 74 | beforeTest { every { smsBridgePropertiesMock.allowMappingWithoutToken }.returns(true) } 75 | describe("user is in no room") { 76 | beforeTest { coEvery { membershipServiceMock.getMembershipsByUserId(userId) }.returns(flowOf()) } 77 | it("should return null") { 78 | cut.getRoomId(userId, null).shouldBeNull() 79 | } 80 | } 81 | describe("user is in one room") { 82 | beforeTest { 83 | coEvery { membershipServiceMock.getMembershipsByUserId(userId) } 84 | .returns(flowOf(membership)) 85 | } 86 | it("should return room id") { 87 | cut.getRoomId(userId, null).shouldBe(membership.roomId) 88 | } 89 | } 90 | describe("user is in more then one room") { 91 | beforeTest { 92 | coEvery { membershipServiceMock.getMembershipsByUserId(userId) } 93 | .returns(flowOf(mockk(), mockk())) 94 | } 95 | it("should return null") { 96 | cut.getRoomId(userId, null).shouldBeNull() 97 | } 98 | } 99 | } 100 | describe("$name not allow mapping without token") { 101 | beforeTest { every { smsBridgePropertiesMock.allowMappingWithoutToken }.returns(false) } 102 | it("should return null") { 103 | cut.getRoomId(userId, null).shouldBeNull() 104 | } 105 | } 106 | } 107 | } 108 | describe("mapping token present") { 109 | beforeTest { every { smsBridgePropertiesMock.allowMappingWithoutToken }.returns(true) } 110 | describe("mapping token in database") { 111 | beforeTest { 112 | coEvery { mappingRepositoryMock.findByUserIdAndMappingToken(userId, 2) } 113 | .returns(mapping) 114 | coEvery { membershipServiceMock.getMembership(mapping.membershipId) } 115 | .returns(membership) 116 | } 117 | it("should return room id from database") { 118 | cut.getRoomId(userId, 2).shouldBe(membership.roomId) 119 | } 120 | } 121 | describe("mapping token not in database") { 122 | beforeTest { 123 | coEvery { mappingRepositoryMock.findByUserIdAndMappingToken(userId, 2) } 124 | .returns(null) 125 | } 126 | include(testFindSingleRoomIdMapping("a")) 127 | } 128 | } 129 | describe("mapping token not present") { 130 | include(testFindSingleRoomIdMapping("b")) 131 | } 132 | } 133 | 134 | afterTest { clearMocks(mappingRepositoryMock, membershipServiceMock, smsBridgePropertiesMock) } 135 | } 136 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/message/MatrixMessageRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.message 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.kotest.matchers.shouldBe 5 | import kotlinx.coroutines.reactive.awaitFirst 6 | import net.folivo.matrix.bot.config.MatrixBotDatabaseAutoconfiguration 7 | import net.folivo.matrix.bot.room.MatrixRoom 8 | import net.folivo.matrix.bridge.sms.SmsBridgeDatabaseConfiguration 9 | import net.folivo.matrix.core.model.MatrixId.RoomId 10 | import org.springframework.boot.autoconfigure.ImportAutoConfiguration 11 | import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest 12 | import org.springframework.data.r2dbc.core.R2dbcEntityTemplate 13 | import org.springframework.data.r2dbc.core.delete 14 | 15 | @DataR2dbcTest 16 | @ImportAutoConfiguration(value = [MatrixBotDatabaseAutoconfiguration::class, SmsBridgeDatabaseConfiguration::class]) 17 | class MatrixMessageRepositoryTest( 18 | cut: MatrixMessageRepository, 19 | db: R2dbcEntityTemplate 20 | ) : DescribeSpec(testBody(cut, db)) 21 | 22 | private fun testBody(cut: MatrixMessageRepository, db: R2dbcEntityTemplate): DescribeSpec.() -> Unit { 23 | return { 24 | val room1 = RoomId("room1", "server") 25 | val room2 = RoomId("room2", "server") 26 | 27 | val message1 = MatrixMessage(room1, "some body 1") 28 | val message2 = MatrixMessage(room1, "some body 2") 29 | val message3 = MatrixMessage(room2, "some body 3") 30 | 31 | beforeSpec { 32 | db.insert(MatrixRoom(room1)).awaitFirst() 33 | db.insert(MatrixRoom(room2)).awaitFirst() 34 | db.insert(message1).awaitFirst() 35 | db.insert(message2).awaitFirst() 36 | db.insert(message3).awaitFirst() 37 | } 38 | 39 | describe(MatrixMessageRepository::deleteByRoomId.name) { 40 | it("should delete all matching rooms") { 41 | cut.count().shouldBe(3) 42 | cut.deleteByRoomId(room1) 43 | cut.count().shouldBe(1) 44 | } 45 | } 46 | 47 | afterSpec { 48 | db.delete().all().awaitFirst() 49 | db.delete().all().awaitFirst() 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/test/kotlin/net/folivo/matrix/bridge/sms/provider/android/AndroidSmsProviderLauncherTest.kt: -------------------------------------------------------------------------------- 1 | package net.folivo.matrix.bridge.sms.provider.android 2 | 3 | import io.kotest.core.spec.style.DescribeSpec 4 | import io.mockk.* 5 | import kotlinx.coroutines.cancelAndJoin 6 | import net.folivo.matrix.bridge.sms.SmsBridgeProperties 7 | import net.folivo.matrix.core.model.MatrixId.EventId 8 | import net.folivo.matrix.core.model.MatrixId.RoomId 9 | import net.folivo.matrix.core.model.events.m.room.message.NoticeMessageEventContent 10 | import net.folivo.matrix.restclient.MatrixClient 11 | 12 | class AndroidSmsProviderLauncherTest : DescribeSpec(testBody()) 13 | 14 | private fun testBody(): DescribeSpec.() -> Unit { 15 | return { 16 | val androidSmsProviderMock: AndroidSmsProvider = mockk() 17 | val smsBridgePropertiesMock: SmsBridgeProperties = mockk { 18 | every { templates.providerReceiveError }.returns("error {error}") 19 | } 20 | val matrixClientMock: MatrixClient = mockk { 21 | coEvery { roomsApi.sendRoomEvent(any(), any(), any(), any(), any()) } 22 | .returns(EventId("event", "server")) 23 | } 24 | 25 | val cut = AndroidSmsProviderLauncher(androidSmsProviderMock, smsBridgePropertiesMock, matrixClientMock) 26 | 27 | describe(AndroidSmsProviderLauncher::startReceiveLoop.name) { 28 | describe("on error") { 29 | describe("default room given") { 30 | val defaultRoom = RoomId("default", "server") 31 | beforeTest { 32 | every { smsBridgePropertiesMock.defaultRoomId }.returns(defaultRoom) 33 | } 34 | it("should notify default room") { 35 | coEvery { androidSmsProviderMock.getAndProcessNewMessages() } 36 | .throws(RuntimeException("meteor")) 37 | val job = cut.startReceiveLoop() 38 | coVerify(timeout = 100) { 39 | matrixClientMock.roomsApi.sendRoomEvent( 40 | defaultRoom, match { it.body == "error meteor" }, 41 | any(), any(), any() 42 | ) 43 | } 44 | job.cancelAndJoin() 45 | } 46 | } 47 | describe("default room not given") { 48 | beforeTest { 49 | every { smsBridgePropertiesMock.defaultRoomId }.returns(null) 50 | } 51 | it("should not notify default room") { 52 | coEvery { androidSmsProviderMock.getAndProcessNewMessages() } 53 | .throws(RuntimeException("meteor")) 54 | val job = cut.startReceiveLoop() 55 | coVerify(exactly = 0, timeout = 100) { 56 | matrixClientMock.roomsApi.sendRoomEvent(any(), any(), any(), any(), any()) 57 | } 58 | job.cancelAndJoin() 59 | } 60 | } 61 | } 62 | } 63 | 64 | afterTest { clearMocks(smsBridgePropertiesMock, androidSmsProviderMock, matrixClientMock) } 65 | } 66 | } -------------------------------------------------------------------------------- /src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | matrix: 2 | bridge: 3 | sms: 4 | defaultRegion: DE 5 | bot: 6 | mode: APPSERVICE 7 | username: "smsbot" 8 | trackMembership: ALL 9 | serverName: matrix-local 10 | migration: 11 | url: jdbc:h2:mem:testdb 12 | username: sa 13 | database: 14 | url: r2dbc:h2:mem:///testdb 15 | username: sa 16 | appservice: 17 | hsToken: 312df522183efd404ec1cd22d2ffa4bbc76a8c1ccf541dd692eef281356bb74e 18 | namespaces: 19 | users: 20 | - localpartRegex: "sms_[0-9]{6,15}" 21 | aliases: 22 | - localpartRegex: "sms_[0-9]{6,15}" 23 | rooms: [ ] 24 | client: 25 | homeServer: 26 | hostname: matrix-synapse 27 | port: 8008 28 | secure: false 29 | token: 30c05ae90a248a4188e620216fa72e349803310ec83e2a77b34fe90be6081f46 30 | 31 | spring: 32 | autoconfigure: 33 | exclude: org.springframework.boot.autoconfigure.data.jdbc.JdbcRepositoriesAutoConfiguration,org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration 34 | 35 | logging: 36 | level: 37 | net.folivo.matrix.bridge.sms: DEBUG 38 | org.springframework.data: DEBUG --------------------------------------------------------------------------------