├── .dockerignore ├── .eslintrc ├── .github ├── CODEOWNERS └── workflows │ ├── changelog.yml │ ├── docker-hub-latest.yml │ ├── docker-hub-release.yml │ ├── sign-off.yml │ ├── tests.yml │ └── triage-incoming.yml ├── .gitignore ├── .node-version ├── .nodeversion ├── .nycrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── changelog.d ├── .keep ├── 360.bugfix └── 365.feature ├── config.sample.yaml ├── config └── config.schema.yaml ├── extras ├── README.md └── xmpp │ ├── mod_register_json.COPYING │ └── mod_register_json.lua ├── package.json ├── pyproject.toml ├── scripts ├── changelog-check.sh ├── check-newsfragment └── towncrier.sh ├── src ├── AutoRegistration.ts ├── Config.ts ├── Deduplicator.ts ├── GatewayHandler.ts ├── MatrixEventHandler.ts ├── MatrixRoomHandler.ts ├── MatrixTypes.ts ├── MessageFormatter.ts ├── Metrics.ts ├── ProfileSync.ts ├── Program.ts ├── ProtoHacks.ts ├── RoomAliasSet.ts ├── RoomSync.ts ├── Util.ts ├── bifrost │ ├── Account.ts │ ├── Events.ts │ ├── Gateway.ts │ ├── Instance.ts │ └── Protocol.ts ├── generate-signing-key.js ├── purple │ ├── PurpleAccount.ts │ ├── PurpleInstance.ts │ └── PurpleProtocol.ts ├── store │ ├── BifrostRemoteUser.ts │ ├── NeDBStore.ts │ ├── Store.ts │ ├── Types.ts │ └── postgres │ │ ├── PgDatastore.ts │ │ └── schema │ │ ├── v1.ts │ │ └── v2.ts └── xmppjs │ ├── GatewayMUCMembership.ts │ ├── GatewayStateResolve.ts │ ├── Jingle.ts │ ├── PresenceCache.ts │ ├── ServiceHandler.ts │ ├── Stanzas.ts │ ├── XHTMLIM.ts │ ├── XJSAccount.ts │ ├── XJSBackendOpts.ts │ ├── XJSConnection.ts │ ├── XJSGateway.ts │ ├── XJSInstance.ts │ └── XMPPConstants.ts ├── start.sh ├── test ├── mocks │ ├── XJSInstance.ts │ ├── dummyprotocol.ts │ ├── intent.ts │ └── store.ts ├── test.ts ├── test_GatewayHandler.ts ├── test_autoregistration.ts ├── test_config.ts ├── test_matrixeventhandler.ts ├── test_messageformatter.ts ├── test_profilesync.ts ├── test_protohacks.ts ├── test_roomsync.ts ├── test_store.ts ├── test_util.ts └── xmppjs │ ├── fixtures.ts │ ├── test_GatewayMUCMembership.ts │ ├── test_GatewayStateResolve.ts │ ├── test_Stanzas.ts │ ├── test_XHTMLIM.ts │ ├── test_XJSAccount.ts │ ├── test_XJSGateway.ts │ ├── test_XJSInstance.ts │ ├── test_presencecache.ts │ └── util.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /lib 3 | /changelog.d 4 | /coverage 5 | /docs 6 | /test -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "parser": "@typescript-eslint/parser", 7 | "parserOptions": { 8 | "ecmaVersion": 9 9 | }, 10 | "plugins": [ 11 | "eslint-plugin-import", 12 | "eslint-plugin-jsdoc", 13 | "@typescript-eslint" 14 | ], 15 | "rules": { 16 | "@typescript-eslint/adjacent-overload-signatures": "error", 17 | "@typescript-eslint/array-type": [ 18 | "error", 19 | { 20 | "default": "array" 21 | } 22 | ], 23 | "@typescript-eslint/ban-types": [ 24 | "error", 25 | { 26 | "types": { 27 | "Object": { 28 | "message": "Avoid using the `Object` type. Did you mean `Record`?" 29 | }, 30 | "Function": { 31 | "message": "Avoid using the `Function` type. Prefer a specific function type, like `() => void`." 32 | }, 33 | "Boolean": { 34 | "message": "Avoid using the `Boolean` type. Did you mean `boolean`?" 35 | }, 36 | "Number": { 37 | "message": "Avoid using the `Number` type. Did you mean `number`?" 38 | }, 39 | "String": { 40 | "message": "Avoid using the `String` type. Did you mean `string`?" 41 | }, 42 | "Symbol": { 43 | "message": "Avoid using the `Symbol` type. Did you mean `symbol`?" 44 | } 45 | } 46 | } 47 | ], 48 | "@typescript-eslint/consistent-type-assertions": "error", 49 | "@typescript-eslint/indent": "error", 50 | "@typescript-eslint/naming-convention": "off", 51 | "@typescript-eslint/no-empty-interface": "error", 52 | "@typescript-eslint/no-explicit-any": "warn", 53 | "@typescript-eslint/no-misused-new": "error", 54 | "@typescript-eslint/no-namespace": "error", 55 | "@typescript-eslint/no-parameter-properties": "off", 56 | "@typescript-eslint/no-shadow": [ 57 | "error", 58 | { 59 | "hoist": "all" 60 | } 61 | ], 62 | "@typescript-eslint/no-unused-expressions": "off", 63 | "@typescript-eslint/no-use-before-define": "off", 64 | "@typescript-eslint/no-var-requires": "error", 65 | "@typescript-eslint/prefer-for-of": "error", 66 | "@typescript-eslint/prefer-function-type": "error", 67 | "@typescript-eslint/prefer-namespace-keyword": "error", 68 | "@typescript-eslint/triple-slash-reference": [ 69 | "error", 70 | { 71 | "path": "always", 72 | "types": "prefer-import", 73 | "lib": "always" 74 | } 75 | ], 76 | "@typescript-eslint/unified-signatures": "error", 77 | "arrow-body-style": "error", 78 | "complexity": "off", 79 | "constructor-super": "error", 80 | "curly": "error", 81 | "dot-notation": "error", 82 | "eqeqeq": [ 83 | "error", 84 | "smart" 85 | ], 86 | "guard-for-in": "error", 87 | "id-blacklist": [ 88 | "error", 89 | "any", 90 | "Number", 91 | "number", 92 | "String", 93 | "string", 94 | "Boolean", 95 | "boolean", 96 | "Undefined", 97 | "undefined" 98 | ], 99 | "id-match": "error", 100 | "import/order": "off", 101 | "jsdoc/check-alignment": "error", 102 | "jsdoc/check-indentation": "error", 103 | "jsdoc/tag-lines": ["error", "any", {"startLines": 1}], 104 | "max-classes-per-file": "warn", 105 | "new-parens": "error", 106 | "no-bitwise": "error", 107 | "no-caller": "error", 108 | "no-cond-assign": "error", 109 | "no-console": "error", 110 | "no-debugger": "error", 111 | "no-empty": "warn", 112 | "no-eval": "error", 113 | "no-fallthrough": "off", 114 | "no-invalid-this": "error", 115 | "no-new-wrappers": "error", 116 | "no-throw-literal": "warn", 117 | "no-trailing-spaces": "error", 118 | "no-undef-init": "error", 119 | "no-underscore-dangle": "error", 120 | "no-unsafe-finally": "error", 121 | "no-unused-expressions": "off", 122 | "no-unused-labels": "error", 123 | "no-use-before-define": "off", 124 | "no-var": "error", 125 | "object-shorthand": "error", 126 | "one-var": [ 127 | "error", 128 | "never" 129 | ], 130 | "prefer-const": "error", 131 | "radix": "off", 132 | "spaced-comment": [ 133 | "error", 134 | "always", 135 | { 136 | "markers": [ 137 | "/" 138 | ] 139 | } 140 | ], 141 | "use-isnan": "error", 142 | "valid-typeof": "off" 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @matrix-org/bridges 2 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | pull_request: 7 | branches: [ develop ] 8 | 9 | jobs: 10 | changelog: 11 | if: ${{ github.base_ref == 'develop' || contains(github.base_ref, 'release-') }} 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | ref: ${{ github.event.pull_request.head.sha }} 17 | fetch-depth: 0 18 | - uses: actions/setup-python@v2 19 | - run: pip install towncrier==22.8.0 20 | - run: scripts/check-newsfragment 21 | env: 22 | PULL_REQUEST_NUMBER: ${{ github.event.number }} -------------------------------------------------------------------------------- /.github/workflows/docker-hub-latest.yml: -------------------------------------------------------------------------------- 1 | # Based on https://github.com/matrix-org/dendrite/blob/master/.github/workflows/docker-hub.yml 2 | 3 | name: "Docker Hub - Latest" 4 | 5 | on: 6 | push: 7 | 8 | env: 9 | DOCKER_NAMESPACE: matrixdotorg 10 | PLATFORMS: linux/amd64 11 | # Only push if this is develop, otherwise we just want to build 12 | PUSH: ${{ github.ref == 'refs/heads/develop' }} 13 | 14 | jobs: 15 | docker-latest: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Check out 19 | uses: actions/checkout@v2 20 | - name: Log in to Docker Hub 21 | uses: docker/login-action@v1 22 | with: 23 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 24 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 25 | 26 | - name: Build image 27 | uses: docker/build-push-action@v2 28 | with: 29 | context: . 30 | file: ./Dockerfile 31 | platforms: ${{ env.PLATFORMS }} 32 | push: ${{ env.PUSH }} 33 | tags: | 34 | ${{ env.DOCKER_NAMESPACE }}/matrix-bifrost:latest -------------------------------------------------------------------------------- /.github/workflows/docker-hub-release.yml: -------------------------------------------------------------------------------- 1 | # Based on https://github.com/matrix-org/dendrite/blob/master/.github/workflows/docker-hub.yml 2 | 3 | name: "Docker Hub - Release" 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | env: 10 | DOCKER_NAMESPACE: matrixdotorg 11 | PLATFORMS: linux/amd64 12 | 13 | jobs: 14 | docker-release: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out 18 | uses: actions/checkout@v2 19 | - name: Get release tag 20 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 21 | - name: Log in to Docker Hub 22 | uses: docker/login-action@v1 23 | with: 24 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 25 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 26 | 27 | - name: Build image 28 | uses: docker/build-push-action@v2 29 | with: 30 | context: . 31 | file: ./Dockerfile 32 | platforms: ${{ env.PLATFORMS }} 33 | push: true 34 | tags: | 35 | ${{ env.DOCKER_NAMESPACE }}/matrix-bifrost:${{ env.RELEASE_VERSION }} -------------------------------------------------------------------------------- /.github/workflows/sign-off.yml: -------------------------------------------------------------------------------- 1 | name: Contribution requirements 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, synchronize] 6 | 7 | jobs: 8 | signoff: 9 | uses: matrix-org/backend-meta/.github/workflows/sign-off.yml@v1.4.0 10 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | paths-ignore: 7 | - changelog.d/**' 8 | pull_request: 9 | branches: [ develop ] 10 | paths-ignore: 11 | - changelog.d/**' 12 | 13 | workflow_dispatch: 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version-file: .node-version 24 | - run: yarn --frozen-lockfile 25 | - run: yarn lint 26 | 27 | test: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | matrix: 31 | node_version: [20, 21] 32 | steps: 33 | - uses: actions/checkout@v3 34 | - name: Use Node.js ${{ matrix.node_version }} 35 | uses: actions/setup-node@v3 36 | with: 37 | node-version: ${{ matrix.node_version }} 38 | - uses: actions-rs/toolchain@v1 39 | with: 40 | toolchain: stable 41 | profile: minimal 42 | - run: yarn --frozen-lockfile 43 | - run: yarn test 44 | -------------------------------------------------------------------------------- /.github/workflows/triage-incoming.yml: -------------------------------------------------------------------------------- 1 | name: Move new issues into the issue triage board 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | triage: 9 | uses: matrix-org/backend-meta/.github/workflows/triage-incoming.yml@v1 10 | with: 11 | project_id: 'PVT_kwDOAIB0Bs4AG0bY' 12 | content_id: ${{ github.event.issue.node_id }} 13 | secrets: 14 | github_access_token: ${{ secrets.ELEMENT_BOT_TOKEN }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | *.db 4 | config.yaml 5 | purple-registration.yaml 6 | bifrost-registration.yaml 7 | *.log* 8 | data/ 9 | .nyc_output/ 10 | lib/ -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.nodeversion: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@istanbuljs/nyc-config-typescript", 3 | "all": true 4 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Hi there! Please read the [CONTRIBUTING.md](https://github.com/matrix-org/matrix-appservice-bridge/blob/develop/CONTRIBUTING.md) guide for all matrix.org bridge 2 | projects. 3 | 4 | ## Matrix-bifrost Guidelines 5 | 6 | - When creating an issue, please clearly state whether you are using libpurple or xmpp.js. If possible, please also state the server and client implementation name and versions. 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build node-purple. We need debian for python3.6, which is needed for node-purple 2 | FROM node:20-bookworm as builder 3 | COPY ./package.json ./package.json 4 | COPY ./yarn.lock ./yarn.lock 5 | COPY ./src ./src 6 | COPY ./tsconfig.json ./tsconfig.json 7 | 8 | # node-purple dependencies 9 | RUN apt-get update && apt-get install --no-install-recommends -y libpurple0 libpurple-dev libglib2.0-dev python3 git build-essential 10 | # This will build the optional dependency node-purple AND compile the typescript. 11 | RUN yarn install --frozen-lockfile --check-files 12 | 13 | # App 14 | FROM node:20-bookworm-slim 15 | 16 | RUN mkdir app 17 | WORKDIR /app 18 | 19 | # Install node-purple runtime dependencies. 20 | RUN apt-get update && apt-get install --no-install-recommends -y libpurple0 pidgin-sipe 21 | COPY ./package.json /app/package.json 22 | COPY ./yarn.lock /app/yarn.lock 23 | 24 | # Don't install devDependencies, or optionals. 25 | RUN yarn --check-files --production --ignore-optional 26 | 27 | # Copy the compiled node-purple module 28 | COPY --from=builder ./node_modules/node-purple /app/node_modules/node-purple 29 | 30 | # Copy compiled JS 31 | COPY --from=builder ./lib /app/lib 32 | 33 | # Copy the schema for validation purposes. 34 | COPY ./config/config.schema.yaml ./config/config.schema.yaml 35 | 36 | VOLUME [ "/data" ] 37 | 38 | # Needed for libpurple symbols to load. See https://github.com/matrix-org/matrix-bifrost/issues/257 39 | ENV LD_PRELOAD="/usr/lib/libpurple.so.0" 40 | 41 | ENTRYPOINT [ "node", \ 42 | "--enable-source-maps", \ 43 | "/app/lib/Program.js", \ 44 | "--port", "5000", \ 45 | "--config", "/data/config.yaml", \ 46 | "--file", "/data/registration.yaml" \ 47 | ] 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # matrix-bifröst 2 | 3 | [![#bifrost:half-shot.uk](https://img.shields.io/matrix/bifrost:half-shot.uk?server_fqdn=matrix.org&label=%23bifrost:half-shot.uk&logo=matrix)](https://matrix.to/#/#bifrost:half-shot.uk) 4 | 5 | General purpose puppeting bridges using libpurple and other backends. 6 | 7 | This bridge is in very active development currently and intended mainly for experimentation and evaluation purposes. 8 | 9 | This has been tested to work on `Node.JS v10` and `Synapse 0.34.0`. 10 | 11 | ## Helping out 12 | 13 | If you wish to file an issue or create a PR, **please read [CONTRIBUTING.md](./CONTRIBUTING.md) first. 14 | 15 | **NOTE: You must read this README carefully as simply installing required dependencies may NOT be enough for some backends** 16 | 17 | ## Backends 18 | 19 | This bridge features multiple backends for spinning up bridges on different types of network. 20 | The following are supported: 21 | * `xmpp.js` 22 | Designed to bridge to XMPP networks directly, without purple. Good for setups requiring an extremely scalable XMPP bridge. Uses XMPP components. 23 | * `node-purple` 24 | Uses libpurple to bridge to a number of networks supported by libpurple2. Good for simple bridges for a small number of users, or for bridging to less available protocols. 25 | `node-purple` is an optional dependency, which is installed by default. We currently don't publish prebuilt packages for it, so you will need to install buildtime dependencies 26 | listed [on the README](https://github.com/matrix-org/node-purple). 27 | 28 | ## Docker 29 | 30 | Both backends are supported in Docker. You can go straight ahead and use the provided Dockerfile 31 | to build the bridge. You can build the docker image with `docker build -t bifrost:latest` and then 32 | run the image with: `docker run -v /your/path/to/data:/data bifrost:latest -p 5000:9555`. 33 | 34 | An image is available on [Dockerhub](https://hub.docker.com/r/matrixdotorg/matrix-bifrost). 35 | 36 | ### Things to note 37 | 38 | - Make sure you store your `config.yaml`, `registration.yaml` inside /data. 39 | - You should configure your `config.yaml`'s `userStoreFile` and `roomStoreFile` to point to files inside `/data` 40 | - The intenal port for the bridge is `5000`, you should map this to an external port in docker. 41 | - Be careful not to leave any config options pointing to `127.0.0.1` / `localhost` as they will not resolve inside docker. 42 | - The exception to this rule is `bridge.domain`, which MUST be your homeserver's URL. 43 | 44 | ## Installing (non-docker) 45 | 46 | ### Dependencies 47 | 48 | Simply run `yarn install` as normal. Dependencies for `node-purple` can in it's [README](https://github.com/matrix-org/node-purple#node-purple) 49 | 50 | ### Installing & Configuring 51 | 52 | **NOTE: You must carefully read the config.sample.yaml and use the bits appropriate for you. Do NOT copy and paste it verbatim as it won't work.** 53 | 54 | ```shell 55 | yarn install # Install dependencies 56 | yarn build # Build files 57 | cp config.sample.yaml config.yaml 58 | # ... Set the domain name, homeserver url, and then review the rest of the config 59 | sed -i "s/domain: \"localhost\"/domain: \"$YOUR_MATRIX_DOMAIN\"/g" config.yaml 60 | ``` 61 | 62 | You must also generate a registration file: 63 | 64 | ```shell 65 | yarn genreg -- -u http://localhost:9555 # Set listener url here. 66 | ``` 67 | 68 | This file should be accessible by your **homeserver**, which will use this file to get the correct url and tokens to push events to. 69 | 70 | For Synapse, this can be done by: 71 | 72 | * Editing `app_service_config_files` in `homeserver.yaml` to include the full path of your registration file generated above. 73 | 74 | ```yaml 75 | app_service_config_files: 76 | - ".../bifrost-registration.yaml" 77 | ``` 78 | 79 | * Restart synapse, if it is running (`synctl restart`) 80 | 81 | 82 | ### XMPP bridge using the xmpp.js backend 83 | 84 | After completing all the above, you should do the following: 85 | * Set the `purple.backend` in `config.yaml` to `xmpp.js` 86 | * Possibly change the registration file alias and user regexes 87 | to be `_xmpp_` instead of `_purple_`. Make sure to replicate those 88 | changes in `config.yaml` 89 | * Setup your XMPP server to support a new component. 90 | * Setup the `purple.backendOpts` options for the new component. 91 | * Setup autoregistration and portals in `config.yaml`. 92 | 93 | ### Starting 94 | 95 | The `start.sh` script will auto preload the build libpurple library and offers a better experience than the system libraries in most cases. Pleas remember to modify the port in the script if you are using a different port. 96 | 97 | If you are not using the `node-purple` backend, you can just start the service with: 98 | 99 | ```shell 100 | yarn start -- -p 9555 101 | ``` 102 | 103 | ## Help 104 | 105 | ### Binding purple accounts to a Matrix User 106 | 107 | The bridge won't do much unless it has accounts to bind. Due to the infancy of the bridge, we still use `~/.purple/accounts.xml` 108 | for the location of all the accounts. Our advice is to create the accounts you want to use on your local machine with Pidgin, and 109 | then copy the `accounts.xml` file to the bridge (where you should be copying the file to `/$BRIDGE_USER/.purple/accounts.xml`). 110 | 111 | Once you have started the bridge, you can instruct it to bind by starting a conversation with the bridge user and 112 | sending `accounts add-existing $PROTOCOL $USERNAME` where the protocol and username are given in the `accounts.xml` file. 113 | 114 | You should also run `accounts enable $PROTOCOL $USERNAME` to enable the account for the bridge, and then it should connect automatically. 115 | 116 | ### My bridge crashed with a segfault 117 | 118 | The `node-purple` rewrite is still not quite bugfree and we are working hard to iron out the kinks in it. We ask that you report 119 | if certain purple plugins cause more crashes, or if anything in particular lead up to it. 120 | ## Testing 121 | 122 | Running the tests is as simple as doing `yarn test` 123 | -------------------------------------------------------------------------------- /changelog.d/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/matrix-bifrost/830084c61cb9cf955658dd2983ed1c98f39ae62f/changelog.d/.keep -------------------------------------------------------------------------------- /changelog.d/360.bugfix: -------------------------------------------------------------------------------- 1 | Fix a few cases where Bifrost may crash if an event fails to be handled. -------------------------------------------------------------------------------- /changelog.d/365.feature: -------------------------------------------------------------------------------- 1 | Use MediaProxy to serve authenticated Matrix media. 2 | -------------------------------------------------------------------------------- /config.sample.yaml: -------------------------------------------------------------------------------- 1 | bridge: 2 | # Your homeserver server_name. 3 | domain: "localhost" 4 | # A internally reachable endpoint for the CS api of the homeserver. 5 | homeserverUrl: "http://localhost:8008" 6 | # Prefix of all users of the bridge. 7 | userPrefix: "_bifrost_" 8 | # Set this to the port you want the bridge to listen on. 9 | appservicePort: 9555 10 | # Config for the media proxy 11 | # required to serve publically accessible URLs to authenticated Matrix media 12 | mediaProxy: 13 | # To generate a .jwk file: 14 | # $ node src/generate-signing-key.js > signingkey.jwk 15 | signingKeyPath: "signingkey.jwk" 16 | # How long should the generated URLs be valid for 17 | ttlSeconds: 3600 18 | # The port for the media proxy to listen on 19 | bindPort: 11111 20 | # The publically accessible URL to the media proxy 21 | publicUrl: "https://bifrost.bridge/media" 22 | 23 | roomRules: [] 24 | # - room: "#badroom:example.com" 25 | # action: "deny" 26 | # - room: "!badroom:example.com" 27 | # action: "deny" 28 | 29 | datastore: 30 | # The datastore engine to use, either "nedb" or "postgres" 31 | engine: "postgres" 32 | 33 | # For NeDB: 34 | # Location of the user and room database files, by default will be stored in the working directory. 35 | # For Postgres: 36 | # A postgres style connection string. 37 | connectionString: "postgres://postgres:pass@localhost/bifrost" 38 | 39 | purple: 40 | # For selecting a specific backend. One of "node-purple", "xmpp-js". 41 | # -- For xmpp.js - You need an existing xmpp server for this to work. 42 | backend: "xmpp-js" 43 | backendOpts: 44 | # endpoint to reach the component on. The default port is 5347 45 | service: "xmpp://localhost:5347" 46 | # domin assigned to the component. 47 | domain: "matrix.localhost" 48 | # password needed by the component. 49 | password: "jam" 50 | jingle: 51 | # Automatically download files from Jingle file transfers. Unsafe for untrusted networks. 52 | autodownload: false 53 | 54 | # Default settings to set for new accounts, useful for node-purple. NOT used for xmpp.js 55 | # defaultAccountSettings: 56 | # # The protocol ID (e.g. prpl-sipe) 57 | # prpl-plugin: 58 | # # A set of strings -> values for a plugin's settings. 59 | # # Consult the documentation for your protocol for options in here 60 | # server: sip.unstable.technology:5061 61 | # encryption-policy: obey-server 62 | # backendOpts: 63 | # # Should the backend output extra logging. 64 | # debugEnabled: false 65 | # # Where are the plugin libraries stored. 66 | # pluginDir: "/usr/lib/purple-2" 67 | # # Where should purple account data be stored. 68 | # dataDir: "./purple-data" 69 | # # Should only one plugin be enabled (to simplify userIds / commands). 70 | # soloProtocol: "prpl-sipe" 71 | # # Extra options for protocols. 72 | # protocolOptions: 73 | # # The protocol ID (e.g. prpl-sipe) 74 | # prpl-plugin: 75 | # # When the user passes in a username to "accounts add", it should use this format. 76 | # # The format will replace % with the passed in username. 77 | # usernameFormat: "%@my-domain" 78 | 79 | # OR 80 | # backend: "node-purple" 81 | # backendOpts: 82 | # # endpoint to reach the component on. The default port is 5347 83 | # service: "xmpp://localhost:5347" 84 | # # domin assigned to the component. 85 | # domain: "matrix.localhost" 86 | # # password needed by the component. 87 | # password: "jam" 88 | 89 | # Matrix forwards room aliases join requests matching a regex in the 90 | # registration file to the owner's bridge, if the room doesn't exist. 91 | # The following options allow you to configure how the purple bridge may 92 | # match those aliases to remote rooms. 93 | portals: 94 | # Enable gateway support for protocols that support them, e.g. xmpp.js 95 | enableGateway: false 96 | # List of regexes to match a alias that can be turned into a bridge. 97 | aliases: 98 | # This matches #_bifrost_ followed by anything 99 | "^_bifrost_(.+)$": 100 | # Use the xmpp-js protocol. 101 | protocol: "xmpp-js" 102 | properties: 103 | # Set room to the first regex match 104 | room: "regex:1" 105 | # Set the server to be conf.localhost 106 | server: "conf.localhost" 107 | 108 | 109 | # Automatically register users with accounts if they join/get invited 110 | # a room with a protocol they are not bound to. 111 | # This is REQUIRED for xmpp.js to work. 112 | autoRegistration: 113 | enabled: true 114 | protocolSteps: 115 | # For xmpp.js, please use: 116 | xmpp-js: 117 | type: "implicit" 118 | parameters: 119 | username: "_@matrix.localhost" 120 | 121 | # Set up access controls for the bridge 122 | # access: 123 | # accountCreation: 124 | # whitelist: 125 | # - "^@.*:yourdomain$" 126 | 127 | # Available subsitution variables for parameters: 128 | # - The users mxid. 129 | # - The users mxid, with an : replaced with _ and the @ discarded. 130 | # - The users domain. 131 | # - The users localpart. 132 | # - The users displayname, or localpart if not set. 133 | # - Generates a 32 char password 134 | # - The MXC url of the users avatar, if available. 135 | 136 | 137 | ## This is how to autoregister folks with prosody (xmpp) 138 | ## with the included lua script in extras. This applies to node-purple (NOT xmpp.js) 139 | # protocolSteps: 140 | # prpl-jabber: 141 | # type: "http" 142 | # path: "http://localhost:5280/register_account/" 143 | # opts: 144 | # method: "post" 145 | # usernameResult: null 146 | # parameters: 147 | # username: "m_" 148 | # nick: "" 149 | # password: "" 150 | # auth_token: "bridge-token" 151 | # ip: "127.0.0.1" 152 | # paramsToStore: 153 | # - password 154 | 155 | # Enable prometheus metrics reporting. 156 | # This will report metrics on /metrics on the same port as the bridge. 157 | metrics: 158 | enabled: true 159 | 160 | provisioning: 161 | # Can users use ""!purple" in a room to bridge it to a group. 162 | enablePlumbing: false 163 | # Required power level to bridge a room into a group. 164 | requiredUserPL: 100 165 | 166 | logging: 167 | # Set the logging level for stdout. 168 | # Lower levels are inclusive of higher levels e.g. info will contain warn and error logging. 169 | console: "info" # "debug", "info", "warn", "error", "off" 170 | # A list of files and their associated logging levels. 171 | files: 172 | "./info.log": "info" 173 | # "./error.log": "error" 174 | # "./warn.log": "warn" 175 | # "./debug.log": "debug" 176 | 177 | # These are specific flags or values to tune the bridge to different setups. 178 | # The defaults are usually fine, but adjust as needed. 179 | tuning: 180 | # Do not send a message or join a room before setting a users profile for 181 | # the first time. This should help clients hide ugly mxids better behind 182 | # displaynames. 183 | waitOnProfileBeforeSend: true 184 | # A nasty hack to check the domain for conf* to see if the PM is coming from a MUC. 185 | # This is only really needed for legacy clients that don't implement xmlns. 186 | # This is specific to the XMPP.js bridge. 187 | # conferencePMFallbackCheck: false 188 | # Don't send messages from the remote protocol until we have seen them join. 189 | # A list of prefixes to check for a userId. 190 | # This is useful for talking to remote IRC users who might not see a message 191 | # until after they have joined. 192 | # waitOnJoinBeforePM: string[]; 193 | -------------------------------------------------------------------------------- /config/config.schema.yaml: -------------------------------------------------------------------------------- 1 | "$schema": "http://json-schema.org/draft-07/schema#" 2 | "$id": "http://matrix.org/bifrost/schema" 3 | type: object 4 | required: ["bridge", "datastore", "purple", "portals"] 5 | properties: 6 | bridge: 7 | type: object 8 | required: ["domain", "homeserverUrl", "userPrefix", "mediaProxy"] 9 | properties: 10 | domain: 11 | type: string 12 | homeserverUrl: 13 | type: string 14 | userPrefix: 15 | type: string 16 | appservicePort: 17 | type: number 18 | mediaProxy: 19 | type: "object" 20 | properties: 21 | signingKeyPath: 22 | type: "string" 23 | ttlSeconds: 24 | type: "integer" 25 | bindPort: 26 | type: "integer" 27 | publicUrl: 28 | type: "string" 29 | required: ["signingKeyPath", "ttlSeconds", "bindPort", "publicUrl"] 30 | datastore: 31 | required: ["engine"] 32 | type: "object" 33 | properties: 34 | engine: 35 | type: string 36 | enum: 37 | - postgres 38 | - nedb 39 | connectionString: 40 | type: string 41 | purple: 42 | type: "object" 43 | properties: 44 | required: ["backend", "backendOpts"] 45 | backend: 46 | type: string 47 | enum: 48 | - xmpp-js 49 | - node-purple 50 | # This may be anything 51 | backendOpts: 52 | type: object 53 | additionalProperties: 54 | # Any type 55 | defaultAccountSettings: 56 | type: object 57 | additionalProperties: 58 | type: object 59 | additionalProperties: 60 | # Any type 61 | portals: 62 | type: "object" 63 | properties: 64 | enableGateway: 65 | type: boolean 66 | aliases: 67 | type: object 68 | propertyNames: 69 | "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" 70 | additionalProperties: 71 | type: "object" 72 | required: ["protocol", "properties"] 73 | properties: 74 | protocol: 75 | type: string 76 | properties: 77 | type: object 78 | additionalProperties: 79 | type: string 80 | autoRegistration: 81 | type: "object" 82 | if: 83 | properties: 84 | enabled: 85 | const: true 86 | then: 87 | required: ["protocolSteps"] 88 | properties: 89 | enabled: 90 | type: boolean 91 | protocolSteps: 92 | type: object 93 | additionalProperties: true 94 | 95 | access: 96 | type: "object" 97 | properties: 98 | accountCreation: 99 | required: ["whitelist"] 100 | properties: 101 | whitelist: 102 | type: "array" 103 | items: 104 | type: "string" 105 | 106 | metrics: 107 | type: "object" 108 | required: ["enabled"] 109 | properties: 110 | enabled: 111 | type: boolean 112 | 113 | provisioning: 114 | type: "object" 115 | properties: 116 | enablePlumbing: 117 | type: boolean 118 | requiredUserPL: 119 | type: number 120 | 121 | logging: 122 | type: object 123 | properties: 124 | console: 125 | type: "string" 126 | enum: ["error", "warn", "info", "debug", "off"] 127 | files: 128 | type: "object" 129 | items: 130 | additionalProperties: 131 | type: "string" 132 | enum: ["error","warn","info","debug"] 133 | 134 | roomRules: 135 | type: array 136 | items: 137 | type: "object" 138 | properties: 139 | room: 140 | type: "string" 141 | pattern: "^(!|#).+:.+$" 142 | action: 143 | type: "string" 144 | enum: ["allow", "deny"] 145 | 146 | 147 | 148 | tuning: 149 | type: "object" 150 | properties: 151 | waitOnProfileBeforeSend: 152 | type: boolean 153 | conferencePMFallbackCheck: 154 | type: boolean 155 | waitOnJoinBeforePM: 156 | type: "array" 157 | items: 158 | type: "string" 159 | -------------------------------------------------------------------------------- /extras/README.md: -------------------------------------------------------------------------------- 1 | Extras 2 | ====== 3 | 4 | This is a collection of helpful things which I have written or modified for use 5 | with the bridge, such as registration plugins for other services. 6 | -------------------------------------------------------------------------------- /extras/xmpp/mod_register_json.COPYING: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | 4 | Copyright (c) 2009-2015 Various Contributors (see individual files and source control) 5 | 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | 9 | this software and associated documentation files (the "Software"), to deal in 10 | 11 | the Software without restriction, including without limitation the rights to 12 | 13 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 14 | 15 | the Software, and to permit persons to whom the Software is furnished to do so, 16 | 17 | subject to the following conditions: 18 | 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | 22 | copies or substantial portions of the Software. 23 | 24 | 25 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | 27 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 28 | 29 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 30 | 31 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 32 | 33 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 34 | 35 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 36 | -------------------------------------------------------------------------------- /extras/xmpp/mod_register_json.lua: -------------------------------------------------------------------------------- 1 | -- Expose a simple token based servlet to handle user registrations from web pages 2 | -- through Base64 encoded JSON. 3 | 4 | -- Updated for use with matrix-bifrost by removing some restrictions 5 | -- from the plugin like verifying an email address. 6 | 7 | -- Copyright (C) 2010 - 2013, Marco Cirillo (LW.Org) 8 | -- Copyright (C) 2018, New Vector Ltd 9 | 10 | local datamanager = require "util.datamanager"; 11 | local usermanager = require "core.usermanager"; 12 | local http = require "net.http"; 13 | local b64_decode = require "util.encodings".base64.decode 14 | local b64_encode = require "util.encodings".base64.encode 15 | local http_event = require "net.http.server".fire_event 16 | local jid_prep = require "util.jid".prep 17 | local jid_split = require "util.jid".split 18 | local json_decode = require "util.json".decode 19 | local nodeprep = require "util.encodings".stringprep.nodeprep 20 | local open, os_time, setmt, type = io.open, os.time, setmetatable, type 21 | local sha1 = require "util.hashes".sha1 22 | local urldecode = http.urldecode 23 | local uuid_gen = require "util.uuid".generate 24 | local timer = require "util.timer" 25 | 26 | module:depends"http"; 27 | 28 | -- Pick up configuration and setup stores/variables. 29 | 30 | local auth_token = module:get_option_string("reg_servlet_auth_token") 31 | local secure = module:get_option_boolean("reg_servlet_secure", true) 32 | local base_path = module:get_option_string("reg_servlet_base", "/register_account/") 33 | local throttle_time = module:get_option_number("reg_servlet_ttime", nil) 34 | local whitelist = module:get_option_set("reg_servlet_wl", {}) 35 | local blacklist = module:get_option_set("reg_servlet_bl", {}) 36 | local fm_patterns = module:get_option("reg_servlet_filtered_mails", {}) 37 | if type(fm_patterns) ~= "table" then fm_patterns = {} end 38 | 39 | local files_base = module.path:gsub("/[^/]+$","") .. "/template/" 40 | 41 | local recent_ips = {} 42 | local pending = {} 43 | local pending_node = {} 44 | 45 | -- Setup hashes data structure 46 | 47 | hashes = { _index = {} } 48 | local hashes_mt = {} ; hashes_mt.__index = hashes_mt 49 | function hashes_mt:add(node, mail) 50 | local _hash = b64_encode(sha1(mail)) 51 | if not self:exists(_hash) then 52 | self[_hash] = node ; self._index[node] = _hash ; self:save() 53 | return true 54 | else 55 | return false 56 | end 57 | end 58 | function hashes_mt:exists(hash) 59 | if hashes[hash] then return true else return false end 60 | end 61 | function hashes_mt:remove(node) 62 | local _hash = self._index[node] 63 | if _hash then 64 | self[_hash] = nil ; self._index[node] = nil ; self:save() 65 | end 66 | end 67 | function hashes_mt:save() 68 | if not datamanager.store("register_json", module.host, "hashes", hashes) then 69 | module:log("error", "Failed to save the mail addresses' hashes store.") 70 | end 71 | end 72 | 73 | local function check_mail(address) 74 | for _, pattern in ipairs(fm_patterns) do 75 | if address:match(pattern) then return false end 76 | end 77 | return true 78 | end 79 | 80 | -- Begin 81 | 82 | local function handle(code, message) return http_event("http-error", { code = code, message = message }) end 83 | local function http_response(event, code, message, headers) 84 | local response = event.response 85 | 86 | if headers then 87 | for header, data in pairs(headers) do response.headers[header] = data end 88 | end 89 | 90 | response.status_code = code 91 | response:send(handle(code, message)) 92 | end 93 | 94 | local function handle_req(event) 95 | module:log("info", "Got Request") 96 | local request = event.request 97 | if secure and not request.secure then return nil end 98 | 99 | if request.method ~= "POST" then 100 | return http_response(event, 405, "Bad method.", {["Allow"] = "POST"}) 101 | end 102 | 103 | local req_body 104 | -- We check that what we have is valid JSON wise else we throw an error... 105 | if not pcall(function() req_body = json_decode(request.body) end) or req_body == nil then 106 | module:log("debug", "Data submitted for user registration by %s failed to Decode.", user) 107 | return http_response(event, 400, "Decoding failed.") 108 | else 109 | -- Decode JSON data and check that all bits are there else throw an error 110 | if req_body["username"] == nil or req_body["password"] == nil or req_body["ip"] == nil or 111 | req_body["auth_token"] == nil then 112 | module:log("debug", "%s supplied an insufficent number of elements or wrong elements for the JSON registration", user) 113 | return http_response(event, 400, "Invalid syntax.") 114 | end 115 | -- Set up variables 116 | local username, password, ip, mail, token = req_body.username, req_body.password, req_body.ip, req_body.mail, req_body.auth_token 117 | local nick = req_body.nick 118 | -- Check if user is an admin of said host 119 | if token ~= auth_token then 120 | module:log("warn", "%s tried to retrieve a registration token for %s@%s", request.ip, username, module.host) 121 | return http_response(event, 401, "Auth token is invalid! The attempt has been logged.") 122 | else 123 | -- Blacklist can be checked here. 124 | if blacklist:contains(ip) then 125 | module:log("warn", "Attempt of reg. submission to the JSON servlet from blacklisted address: %s", ip) 126 | return http_response(event, 403, "The specified address is blacklisted, sorry.") 127 | end 128 | 129 | --if not check_mail(mail) then 130 | -- module:log("warn", "%s attempted to use a mail address (%s) matching one of the forbidden patterns.", ip, mail) 131 | -- return http_response(event, 403, "Requesting to register using this E-Mail address is forbidden, sorry.") 132 | --end 133 | 134 | -- We first check if the supplied username for registration is already there. 135 | -- And nodeprep the username 136 | username = nodeprep(username) 137 | if not username then 138 | module:log("debug", "An username containing invalid characters was supplied: %s", req_body["username"]) 139 | return http_response(event, 406, "Supplied username contains invalid characters, see RFC 6122.") 140 | else 141 | if pending_node[username] then 142 | module:log("warn", "%s attempted to submit a registration request but another request for that user (%s) is pending", ip, username) 143 | return http_response(event, 401, "Another user registration by that username is pending.") 144 | end 145 | 146 | if not usermanager.user_exists(username, module.host) then 147 | -- if username fails to register successive requests shouldn't be throttled until one is successful. 148 | if throttle_time and not whitelist:contains(ip) then 149 | if not recent_ips[ip] then 150 | recent_ips[ip] = os_time() 151 | else 152 | if os_time() - recent_ips[ip] < throttle_time then 153 | recent_ips[ip] = os_time() 154 | module:log("warn", "JSON Registration request from %s has been throttled.", req_body["ip"]) 155 | return http_response(event, 503, "Request throttled, wait a bit and try again.") 156 | end 157 | recent_ips[ip] = os_time() 158 | end 159 | end 160 | 161 | --local uuid = uuid_gen() 162 | --if not hashes:add(username, mail) then 163 | -- module:log("warn", "%s (%s) attempted to register to the server with an E-Mail address we already possess the hash of.", username, ip) 164 | -- return http_response(event, 409, "The E-Mail Address provided matches the hash associated to an existing account.") 165 | --end 166 | --pending[uuid] = { node = username, password = password, ip = ip } 167 | --pending_node[username] = uuid 168 | 169 | --timer.add_task(300, function() 170 | -- if pending[uuid] then 171 | -- pending[uuid] = nil 172 | -- pending_node[username] = nil 173 | -- hashes:remove(username) 174 | -- end 175 | --end) 176 | module:log("info", "%s submitted a registration request", username) 177 | local ok, error = usermanager.create_user(username, password, module.host) 178 | if ok then 179 | jid = username.."@"..module.host 180 | if nick ~= nil then 181 | local extra_data = {} 182 | extra_data["nick"] = nick 183 | datamanager.store(prepped_username, module.host, "account_details", extra_data) 184 | end 185 | module:fire_event( 186 | "user-registered", 187 | { username = username, host = module.host, source = "mod_register_json", session = { ip = ip } } 188 | ) 189 | module:log("info", "Account %s@%s is successfully verified and activated", username, module.host) 190 | return jid 191 | else 192 | module:log("error", "User creation failed: "..error) 193 | return http_response(event, 500, "Encountered server error while creating the user: "..error) 194 | end 195 | else 196 | module:log("debug", "%s registration data submission failed (user already exists)", username) 197 | return http_response(event, 409, "User already exists.") 198 | end 199 | end 200 | end 201 | end 202 | end 203 | 204 | local function open_file(file) 205 | local f, err = open(file, "rb"); 206 | if not f then return nil end 207 | 208 | local data = f:read("*a") ; f:close() 209 | return data 210 | end 211 | 212 | local function r_template(event, type) 213 | local data = open_file(files_base..type.."_t.html") 214 | if data then 215 | data = data:gsub("%%REG%-URL", base_path.."verify/") 216 | return data 217 | else return http_response(event, 500, "Failed to obtain template.") end 218 | end 219 | 220 | 221 | local function handle_user_deletion(event) 222 | local user, hostname = event.username, event.host 223 | if hostname == module.host then hashes:remove(user) end 224 | end 225 | 226 | -- Set it up! 227 | 228 | hashes = datamanager.load("register_json", module.host, "hashes") or hashes ; setmt(hashes, hashes_mt) 229 | 230 | module:provides("http", { 231 | default_path = base_path, 232 | route = { 233 | GET = handle_req, 234 | ["GET /"] = handle_req, 235 | POST = handle_req, 236 | ["POST /"] = handle_req 237 | } 238 | }) 239 | 240 | module:hook_global("user-deleted", handle_user_deletion, 10); 241 | 242 | -- Reloadability 243 | 244 | module.save = function() return { hashes = hashes } end 245 | module.restore = function(data) hashes = data.hashes or { _index = {} } ; setmt(hashes, hashes_mt) end 246 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matrix-bifrost", 3 | "version": "1.0.3", 4 | "description": "Multi protocol bridging for Matrix.", 5 | "engines": { 6 | "node": ">=20" 7 | }, 8 | "main": "lib/Program.js", 9 | "scripts": { 10 | "prepare": "npm run build", 11 | "build": "tsc", 12 | "lint": "eslint -c .eslintrc 'test/**/*.ts' 'src/**/*.ts'", 13 | "start": "node --enable-source-maps lib/Program.js -c config.yaml", 14 | "genreg": "node lib/Program.js -r -c config.yaml", 15 | "test": "mocha -r ts-node/register test/test.ts test/*.ts test/**/*.ts", 16 | "changelog": "scripts/towncrier.sh", 17 | "coverage": "nyc mocha -r ts-node/register test/test.ts test/*.ts test/**/*.ts" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/matrix-org/matrix-bifrost.git" 22 | }, 23 | "keywords": [], 24 | "author": "Will Hunt ", 25 | "license": "Apache-2.0", 26 | "bugs": { 27 | "url": "https://github.com/matrix-org/matrix-bifrost/issues" 28 | }, 29 | "homepage": "https://github.com/matrix-org/matrix-bifrost", 30 | "dependencies": { 31 | "@xmpp/component": "^0.12.0", 32 | "@xmpp/component-core": "^0.12.0", 33 | "@xmpp/jid": "^0.12.0", 34 | "@xmpp/reconnect": "^0.12.0", 35 | "@xmpp/xml": "^0.12.0", 36 | "fast-xml-parser": "^4.2.5", 37 | "html-entities": "^2.4.0", 38 | "htmlparser2": "^9.1.0", 39 | "leven": "^3.0.0", 40 | "marked": "^11.1.1", 41 | "nedb": "^1.8.0", 42 | "matrix-appservice-bridge": "^10.2.0", 43 | "pg": "8.11.3", 44 | "prom-client": "^15.1.0", 45 | "quick-lru": "^5.0.0" 46 | }, 47 | "devDependencies": { 48 | "@istanbuljs/nyc-config-typescript": "^1.0.2", 49 | "@tsconfig/node20": "20.1.2", 50 | "@types/chai": "^4.3.11", 51 | "@types/mocha": "^9.0.0", 52 | "@types/node": "^20", 53 | "@types/pg": "^7.14.5", 54 | "@types/xmpp__jid": "^1.3.5", 55 | "@types/xmpp__xml": "^0.6.1", 56 | "@typescript-eslint/eslint-plugin": "^6.18.0", 57 | "@typescript-eslint/eslint-plugin-tslint": "^6.18.0", 58 | "@typescript-eslint/parser": "^6.18.0", 59 | "chai": "^4", 60 | "eslint": "^8.56.0", 61 | "eslint-plugin-import": "^2.29.1", 62 | "eslint-plugin-jsdoc": "^48.0.2", 63 | "mocha": "^9.0.3", 64 | "mock-require": "^3.0.3", 65 | "nyc": "^15.1.0", 66 | "ts-node": "^10.9.2", 67 | "typescript": "^5.3.3" 68 | }, 69 | "optionalDependencies": { 70 | "node-purple": "git+https://github.com/matrix-org/node-purple#1adfe21219863824a1fcb4c1de35b5b44cccca37" 71 | }, 72 | "nyc": { 73 | "check-coverage": true, 74 | "per-file": false, 75 | "lines": 85, 76 | "statements": 85, 77 | "functions": 75, 78 | "branches": 75, 79 | "include": [ 80 | "src" 81 | ], 82 | "exclude": [ 83 | "src/Program.ts" 84 | ], 85 | "reporter": [ 86 | "lcov", 87 | "text-summary" 88 | ], 89 | "extension": [ 90 | ".ts" 91 | ], 92 | "require": [ 93 | "ts-node/register" 94 | ], 95 | "cache": true, 96 | "all": true, 97 | "instrument": true, 98 | "sourceMap": true, 99 | "report-dir": "./coverage" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | # The name of your Python package 3 | filename = "CHANGELOG.md" 4 | directory = "changelog.d" 5 | issue_format = "[\\#{issue}](https://github.com/matrix-org/matrix-bifrost/issues/{issue})" 6 | 7 | [[tool.towncrier.type]] 8 | directory = "feature" 9 | name = "Features" 10 | showcontent = true 11 | 12 | [[tool.towncrier.type]] 13 | directory = "bugfix" 14 | name = "Bugfixes" 15 | showcontent = true 16 | 17 | [[tool.towncrier.type]] 18 | directory = "doc" 19 | name = "Improved Documentation" 20 | showcontent = true 21 | 22 | [[tool.towncrier.type]] 23 | directory = "removal" 24 | name = "Deprecations and Removals" 25 | showcontent = true 26 | 27 | [[tool.towncrier.type]] 28 | directory = "misc" 29 | name = "Internal Changes" 30 | showcontent = true 31 | -------------------------------------------------------------------------------- /scripts/changelog-check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pip3 install towncrier==19.2.0 3 | python3 -m towncrier.check --compare-with=origin/develop 4 | -------------------------------------------------------------------------------- /scripts/check-newsfragment: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # A script which checks that an appropriate news file has been added on this 4 | # branch. 5 | 6 | 7 | echo -e "+++ \033[32mChecking newsfragment\033[m" 8 | 9 | set -e 10 | 11 | # make sure that origin/develop is up to date 12 | git remote set-branches --add origin develop 13 | git fetch -q origin develop 14 | 15 | pr="$PULL_REQUEST_NUMBER" 16 | 17 | # Print a link to the contributing guide if the user makes a mistake 18 | CONTRIBUTING_GUIDE_TEXT="!! Please see the contributing guide for help writing your changelog entry: 19 | https://github.com/matrix-org/matrix-appservice-bridge/blob/develop/CONTRIBUTING.md#%EF%B8%8F-pull-requests" 20 | 21 | # If check-newsfragment returns a non-zero exit code, print the contributing guide and exit 22 | python3 -m towncrier.check --compare-with=origin/develop || (echo -e "$CONTRIBUTING_GUIDE_TEXT" >&2 && exit 1) 23 | 24 | echo 25 | echo "--------------------------" 26 | echo 27 | 28 | matched=0 29 | for f in $(git diff --diff-filter=d --name-only FETCH_HEAD... -- changelog.d); do 30 | # check that any added newsfiles on this branch end with a full stop. 31 | lastchar=$(tr -d '\n' < "$f" | tail -c 1) 32 | if [ "$lastchar" != '.' ] && [ "$lastchar" != '!' ]; then 33 | echo -e "\e[31mERROR: newsfragment $f does not end with a '.' or '!'\e[39m" >&2 34 | echo -e "$CONTRIBUTING_GUIDE_TEXT" >&2 35 | exit 1 36 | fi 37 | 38 | # see if this newsfile corresponds to the right PR 39 | [[ -n "$pr" && "$f" == changelog.d/"$pr".* ]] && matched=1 40 | done 41 | 42 | if [[ -n "$pr" && "$matched" -eq 0 ]]; then 43 | echo -e "\e[31mERROR: Did not find a news fragment with the right number: expected changelog.d/$pr.*.\e[39m" >&2 44 | echo -e "$CONTRIBUTING_GUIDE_TEXT" >&2 45 | exit 1 46 | fi 47 | -------------------------------------------------------------------------------- /scripts/towncrier.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | VERSION=`python3 -c "import json; f = open('./package.json', 'r'); v = json.loads(f.read())['version']; f.close(); print(v)"` 3 | towncrier build --version $VERSION $1 4 | -------------------------------------------------------------------------------- /src/Config.ts: -------------------------------------------------------------------------------- 1 | import { IAutoRegStep } from "./AutoRegistration"; 2 | import { IRoomAlias } from "./RoomAliasSet"; 3 | import { IXJSBackendOpts } from "./xmppjs/XJSBackendOpts"; 4 | import { PgDataStoreOpts } from "./store/postgres/PgDatastore"; 5 | import { IAccountExtraConfig } from "./bifrost/Account"; 6 | import { IPurpleBackendOpts } from "./purple/PurpleInstance"; 7 | 8 | export type ConfigValue = {[key: string]: ConfigValue}|string|boolean|number|null; 9 | 10 | export class Config { 11 | 12 | public readonly bridge: IConfigBridge = { 13 | domain: "", 14 | homeserverUrl: "", 15 | userPrefix: "_bifrost_", 16 | appservicePort: 9555, 17 | mediaProxy: { 18 | signingKeyPath: "", 19 | ttlSeconds: 0, 20 | bindPort: 0, 21 | publicUrl: "" 22 | }, 23 | }; 24 | 25 | public readonly roomRules: IConfigRoomRule[] = []; 26 | 27 | public readonly datastore: IConfigDatastore = { 28 | engine: "nedb", 29 | connectionString: "nedb://.", 30 | opts: undefined, 31 | }; 32 | 33 | public readonly purple: IConfigPurple = { 34 | backendOpts: undefined, 35 | backend: "node-purple", 36 | defaultAccountSettings: undefined, 37 | }; 38 | 39 | public readonly autoRegistration: IConfigAutoReg = { 40 | registrationNameCacheSize: 15000, 41 | enabled: false, 42 | protocolSteps: undefined, 43 | }; 44 | 45 | public readonly bridgeBot: IConfigBridgeBot = { 46 | displayname: "Bifrost Bot", 47 | accounts: [], 48 | }; 49 | 50 | public readonly logging: IConfigLogging = { 51 | console: "info", 52 | }; 53 | 54 | public readonly profile: IConfigProfile = { 55 | updateInterval: 60000 * 15, 56 | }; 57 | 58 | public readonly portals: IConfigPortals = { 59 | aliases: undefined, 60 | enableGateway: false, 61 | }; 62 | 63 | public readonly metrics: IConfigMetrics = { 64 | enabled: false, 65 | }; 66 | 67 | public readonly provisioning: IConfigProvisioning = { 68 | enablePlumbing: true, 69 | requiredUserPL: 100, 70 | }; 71 | 72 | public readonly tuning: IConfigTuning = { 73 | waitOnProfileBeforeSend: true, 74 | conferencePMFallbackCheck: false, 75 | waitOnJoinBeforePM: [], 76 | }; 77 | 78 | public readonly access: IConfigAccessControl = { }; 79 | 80 | public getRoomRule(roomIdOrAlias?: string) { 81 | const aliasRule = this.roomRules.find((r) => r.room === roomIdOrAlias); 82 | if (aliasRule && aliasRule.action === "deny") { 83 | return "deny"; 84 | } 85 | const roomIdRule = this.roomRules.find((r) => r.room === roomIdOrAlias); 86 | return roomIdRule?.action || "allow"; 87 | } 88 | 89 | /** 90 | * Apply a set of keys and values over the default config. 91 | * 92 | * @param newConfig Config keys 93 | * @param configLayer Private parameter 94 | */ 95 | public ApplyConfig(newConfig: ConfigValue, configLayer: ConfigValue|Config = this) { 96 | Object.keys(newConfig).forEach((key) => { 97 | if (typeof(configLayer[key]) === "object" && 98 | !Array.isArray(configLayer[key])) { 99 | this.ApplyConfig(newConfig[key], this[key]); 100 | return; 101 | } 102 | configLayer[key] = newConfig[key]; 103 | }); 104 | } 105 | } 106 | 107 | export interface IConfigBridge { 108 | domain: string; 109 | homeserverUrl: string; 110 | userPrefix: string; 111 | appservicePort?: number; 112 | mediaProxy: { 113 | signingKeyPath: string; 114 | ttlSeconds: number; 115 | bindPort: number; 116 | publicUrl: string; 117 | }, 118 | } 119 | 120 | export interface IConfigPurple { 121 | backendOpts: IPurpleBackendOpts|IXJSBackendOpts|undefined; 122 | backend: "node-purple"|"xmpp-js"; 123 | defaultAccountSettings?: {[key: string]: IAccountExtraConfig}; 124 | } 125 | 126 | export interface IConfigAutoReg { 127 | enabled: boolean; 128 | protocolSteps: {[protocol: string]: IAutoRegStep} | undefined; 129 | registrationNameCacheSize: number; 130 | } 131 | 132 | export interface IConfigBridgeBot { 133 | displayname: string; 134 | accounts: IBridgeBotAccount[]; // key -> parameter value 135 | } 136 | 137 | export interface IBridgeBotAccount { 138 | name: string; 139 | protocol: string; 140 | } 141 | 142 | export interface IConfigProfile { 143 | updateInterval: number; 144 | } 145 | 146 | export interface IConfigPortals { 147 | aliases: {[regex: string]: IRoomAlias} | undefined; 148 | enableGateway: boolean; 149 | } 150 | 151 | export interface IConfigProvisioning { 152 | enablePlumbing: boolean; 153 | requiredUserPL: number; 154 | } 155 | 156 | export interface IConfigAccessControl { 157 | accountCreation?: { 158 | whitelist?: string[], 159 | }; 160 | } 161 | interface IConfigMetrics { 162 | enabled: boolean; 163 | } 164 | 165 | interface IConfigLogging { 166 | console: "debug"|"info"|"warn"|"error"|"off"; 167 | files?: {[filename: string]: "debug"|"info"|"warn"|"error"}; 168 | } 169 | 170 | interface IConfigTuning { 171 | // Don't send a message or join a room before setting a profile picture 172 | waitOnProfileBeforeSend: boolean; 173 | // A nasty hack to check the domain for conf* to see if the PM is coming from a MUC. 174 | // This is only really needed for legacy clients that don't implement xmlns 175 | conferencePMFallbackCheck: boolean; 176 | // Don't send messages from the remote protocol until we have seen them join. 177 | // A list of prefixes to check. 178 | waitOnJoinBeforePM: string[]; 179 | } 180 | 181 | export interface IConfigDatastore { 182 | engine: "nedb"|"postgres"; 183 | connectionString: string; 184 | opts: undefined|PgDataStoreOpts; 185 | } 186 | 187 | export interface IConfigRoomRule { 188 | /** 189 | * Room ID or alias 190 | */ 191 | room: string; 192 | /** 193 | * Should the room be allowed, or denied. 194 | */ 195 | action: "allow"|"deny"; 196 | } 197 | -------------------------------------------------------------------------------- /src/Deduplicator.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "matrix-appservice-bridge"; 2 | import leven from "leven"; 3 | 4 | const log = new Logger("Deduplicator"); 5 | 6 | const LEVEN_THRESHOLD = 0.1; 7 | 8 | /** 9 | * This is another bodge class to determine whether messages are duplicates in the UI. 10 | * Simply put, Pidgin is designed as a client-side app so that messages will usuaully 11 | * arrive on one connection, and messages sent from the app will only be displayed 12 | * when recieved down-the-line. 13 | * 14 | * However we are a bridge and must try out best to filter out messages coming from 15 | * us. Since we don't have any IDs and can't really make use of the matrix-appservice-irc 16 | * system of "the chosen one" (as our own messages get echoed back to us), we have to hash 17 | * the sent message with the room name and check it against a table. 18 | */ 19 | 20 | export class Deduplicator { 21 | public static hashMessage(roomName: string, sender: string, body: string): string { 22 | return `${sender}/${roomName}/${body}`; 23 | } 24 | 25 | private expectedMessages: string[]; 26 | private usersInRoom: {[roomName: string]: number}; 27 | private chosenOnes: Map; 28 | private waitJoinList: Map void>; 29 | 30 | constructor() { 31 | this.expectedMessages = []; 32 | this.usersInRoom = {}; 33 | this.chosenOnes = new Map(); 34 | this.waitJoinList = new Map(); 35 | } 36 | 37 | public waitForJoinResolve(roomId: string, userId: string) { 38 | if (this.waitJoinList.has(`${roomId}:${userId}`)) { 39 | this.waitJoinList.get(`${roomId}:${userId}`)!(); 40 | } 41 | } 42 | 43 | public waitForJoin(roomId: string, userId: string, timeoutMs: number = 60000) { 44 | log.debug(`Waiting for ${userId} to join ${roomId}..`); 45 | let timeout: NodeJS.Timeout; 46 | return new Promise((resolve, reject) => { 47 | this.waitJoinList.set(`${roomId}:${userId}`, resolve); 48 | timeout = setTimeout(() => reject("Wait for join timeout expired"), timeoutMs); 49 | }).then(() => { 50 | clearTimeout(timeout); 51 | }); 52 | } 53 | 54 | public isTheChosenOneForRoom(roomName: string, remoteId: string) { 55 | const one = this.chosenOnes.get(roomName); 56 | if (one === undefined) { 57 | log.debug("Assigning a new chosen one for", roomName); 58 | this.chosenOnes.set(roomName, remoteId); 59 | return true; 60 | } 61 | return one === remoteId; 62 | } 63 | 64 | public removeChosenOne(roomName: string, remoteId: string) { 65 | if (this.chosenOnes.get(roomName) === remoteId) { 66 | this.chosenOnes.delete(roomName); 67 | } 68 | } 69 | 70 | public removeChosenOneFromAllRooms(theone: string) { 71 | this.chosenOnes.forEach((acct, key) => { 72 | if (acct === theone) { 73 | this.chosenOnes.delete(key); 74 | } 75 | }); 76 | } 77 | 78 | public incrementRoomUsers(roomName: string) { 79 | log.debug("adding a user to ", roomName); 80 | this.usersInRoom[roomName] = (this.usersInRoom[roomName] || 0) + 1; 81 | } 82 | 83 | public decrementRoomUsers(roomName: string) { 84 | log.debug("removing a user from ", roomName); 85 | this.usersInRoom[roomName] = Math.max(0, (this.usersInRoom[roomName] || 0) - 1); 86 | } 87 | 88 | public insertMessage(roomName: string, sender: string, body: string) { 89 | const h = Deduplicator.hashMessage(roomName, sender, body); 90 | const toAdd = this.usersInRoom[roomName]; 91 | log.debug(`Inserted ${toAdd} hash(es) for (${sender}/${roomName}/${body}):`, h); 92 | for (let i = 0; i < toAdd; i++) { 93 | this.expectedMessages.push(h); 94 | } 95 | } 96 | 97 | public async checkAndRemove(roomName: string, sender: string, body: string) { 98 | const start = `${sender}/${roomName}/`; 99 | const index = this.expectedMessages.findIndex((hash) => { 100 | if (!hash.startsWith(start)) { 101 | return false; 102 | } 103 | hash = hash.slice(start.length); 104 | const l = leven(hash, body) / hash.length; 105 | return l <= LEVEN_THRESHOLD; 106 | }); 107 | if (index !== -1) { 108 | this.expectedMessages.splice(index, 1); 109 | return true; 110 | } 111 | return false; 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/MatrixTypes.ts: -------------------------------------------------------------------------------- 1 | import { MatrixUser, RemoteUser, MatrixRoom, RemoteRoom, WeakEvent, UserMembership } from "matrix-appservice-bridge"; 2 | 3 | /** 4 | * This is actually just a matrix event, as far as we care. 5 | */ 6 | export interface IPublicRoomsResponse { 7 | total_room_count_estimate: number; 8 | chunk: IPublicRoom[]; 9 | } 10 | 11 | export interface IPublicRoom { 12 | aliases: string[]; // Aliases of the room. May be empty. 13 | canonical_alias: string|undefined; // The canonical alias of the room, if any. 14 | name: string|undefined; // The name of the room, if any. 15 | num_joined_members: number; // The number of members joined to the room. 16 | room_id: string; // The ID of the room. 17 | topic: string|undefined; // The topic of the room, if any. 18 | world_readable: boolean; // Whether the room may be viewed by guest users without joining. 19 | guest_can_join: boolean; // Whether guest users may join the room and participate in it. 20 | // If they can, they will be subject to ordinary power level rules like any other user. 21 | avatar_url: string|undefined; // The URL for the room's avatar, if one is set. 22 | } 23 | 24 | 25 | export interface MatrixMembershipEvent extends WeakEvent { 26 | content: { 27 | membership: "join"|"invite"|"leave"|"ban"; 28 | displayname?: string; 29 | avatar_url?: string; 30 | reason?: string; 31 | } 32 | state_key: string; 33 | } 34 | 35 | export interface IMatrixMsgContents { 36 | msgtype: string; 37 | body: string; 38 | remote_id?: string; 39 | info?: {mimetype: string, size: number}; 40 | "m.relates_to"?: { 41 | "event_id": string, 42 | rel_type: "m.replace", 43 | }; 44 | "m.new_content"?: IMatrixMsgContents; 45 | formatted_body?: string; 46 | format?: "org.matrix.custom.html"; 47 | [key: string]: any|undefined; 48 | } 49 | 50 | export interface MatrixMessageEvent extends WeakEvent { 51 | content: IMatrixMsgContents; 52 | } 53 | -------------------------------------------------------------------------------- /src/Metrics.ts: -------------------------------------------------------------------------------- 1 | import { AgeCounters, Bridge, PrometheusMetrics } from "matrix-appservice-bridge"; 2 | import { Counter, Histogram } from "prom-client"; 3 | 4 | interface IBridgeGauges { 5 | matrixRoomConfigs: number; 6 | remoteRoomConfigs: number; 7 | matrixGhosts: number; 8 | remoteGhosts: number; 9 | matrixRoomsByAge: AgeCounters; 10 | remoteRoomsByAge: AgeCounters; 11 | matrixUsersByAge: AgeCounters; 12 | remoteUsersByAge: AgeCounters; 13 | } 14 | 15 | export class Metrics { 16 | public static init(bridge: Bridge) { 17 | this.metrics = bridge.getPrometheusMetrics(); 18 | this.metrics.registerBridgeGauges(() => this.bridgeGauges); 19 | this.remoteCallCounter = this.metrics.addCounter({ 20 | name: "remote_api_calls", 21 | help: "Count of the number of remote API calls made", 22 | labels: ["method"], 23 | }); 24 | this.matrixRequest = this.metrics.addTimer({ 25 | name: "matrix_request_seconds", 26 | help: "Histogram of processing durations of received Matrix messages", 27 | labels: ["outcome"], 28 | }); 29 | this.remoteRequest = this.metrics.addTimer({ 30 | name: "remote_request_seconds", 31 | help: "Histogram of processing durations of received remote messages", 32 | labels: ["outcome"], 33 | }); 34 | } 35 | 36 | public static requestOutcome(isRemote: boolean, duration: number, outcome: string) { 37 | if (!this.metrics) { 38 | return; 39 | } 40 | (isRemote ? this.remoteRequest : this.matrixRequest).observe({outcome}, duration / 1000); 41 | } 42 | 43 | public static incRemoteGhosts(n: number) { 44 | this.bridgeGauges.remoteGhosts++; 45 | } 46 | 47 | public static decRemoteGhosts(n: number) { 48 | this.bridgeGauges.remoteGhosts--; 49 | } 50 | 51 | public static remoteCall(method: string) { 52 | if (!this.metrics) { return; } 53 | this.remoteCallCounter.inc({method}); 54 | } 55 | 56 | private static metrics; 57 | private static remoteCallCounter: Counter; 58 | private static remoteRequest: Histogram; 59 | private static matrixRequest: Histogram; 60 | private static bridgeGauges: IBridgeGauges = { 61 | matrixRoomConfigs: 0, 62 | remoteRoomConfigs: 0, 63 | matrixGhosts: 0, 64 | remoteGhosts: 0, 65 | matrixRoomsByAge: new AgeCounters(), 66 | remoteRoomsByAge: new AgeCounters(), 67 | matrixUsersByAge: new AgeCounters(), 68 | remoteUsersByAge: new AgeCounters(), 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/ProfileSync.ts: -------------------------------------------------------------------------------- 1 | import { IBifrostAccount, IProfileProvider } from "./bifrost/Account"; 2 | import * as _fs from "fs"; 3 | import * as path from "path"; 4 | import { BifrostProtocol } from "./bifrost/Protocol"; 5 | import { Logger, MatrixUser, Bridge } from "matrix-appservice-bridge"; 6 | import { Config } from "./Config"; 7 | import { IStore} from "./store/Store"; 8 | import { BifrostRemoteUser } from "./store/BifrostRemoteUser"; 9 | 10 | const log = new Logger("ProfileSync"); 11 | 12 | export class ProfileSync { 13 | constructor(private bridge: Bridge, private config: Config, private store: IStore) { } 14 | 15 | public async updateProfile( 16 | protocol: BifrostProtocol, 17 | senderId: string, 18 | account: IProfileProvider, 19 | force: boolean = false, 20 | senderIdToLookup?: string, 21 | ) { 22 | senderIdToLookup = senderIdToLookup ? senderIdToLookup : senderId; 23 | const {matrixUser, remoteUser} = await this.getOrCreateStoreUsers(protocol, senderId); 24 | const lastCheck = matrixUser.get("last_check"); 25 | matrixUser.set("last_check", Date.now()); 26 | if (!force && 27 | lastCheck != null && (Date.now() - lastCheck) < this.config.profile.updateInterval) { 28 | return; // Don't need to check. 29 | } 30 | log.debug( 31 | `Checking for profile updates for ${matrixUser.getId()} since their last_check time expired ${lastCheck}`); 32 | const remoteProfileSet: 33 | { 34 | nick: string|undefined, 35 | name: string, 36 | avatar_uri: string|undefined, 37 | } = { 38 | nick: undefined, 39 | name: senderId, 40 | avatar_uri: undefined, 41 | }; 42 | let buddy; 43 | if ((account as IBifrostAccount).getBuddy !== undefined) { 44 | buddy = (account as IBifrostAccount).getBuddy(remoteUser.username); 45 | } 46 | if (buddy === undefined) { 47 | try { 48 | log.info("Fetching user info for", senderIdToLookup); 49 | const uinfo = await account.getUserInfo(senderIdToLookup); 50 | log.debug("getUserInfo got:", uinfo); 51 | remoteProfileSet.nick = uinfo.Nickname as string || uinfo["Display name"] as string; 52 | if (uinfo.Avatar) { 53 | remoteProfileSet.avatar_uri = uinfo.Avatar as string; 54 | } 55 | // XXX: This is dependant on the protocol. 56 | remoteProfileSet.name = (uinfo["Full Name"] || uinfo["User ID"] || senderId) as string; 57 | } catch (ex) { 58 | log.info("Couldn't fetch user info for ", remoteUser.username); 59 | } 60 | } else { 61 | remoteProfileSet.name = buddy.name; 62 | remoteProfileSet.nick = buddy.nick; 63 | remoteProfileSet.avatar_uri = buddy.icon_path; 64 | } 65 | 66 | const errors: Error[] = []; 67 | 68 | const intent = this.bridge.getIntent(matrixUser.getId()); 69 | { 70 | let displayName: string | undefined; 71 | if (remoteProfileSet.nick && matrixUser.get("displayname") !== remoteProfileSet.nick) { 72 | log.debug(`Got a nick "${remoteProfileSet.nick}", setting`); 73 | displayName = remoteProfileSet.nick; 74 | } else if (!matrixUser.get("displayname") && remoteProfileSet.name) { 75 | log.debug(`Got a name "${remoteProfileSet.name}", setting`); 76 | // Don't ever set the name (ugly) over the nick unless we have never set it. 77 | // Nicks come and go depending on the libpurple cache and whether the user 78 | // is online (in XMPPs case at least). 79 | displayName = remoteProfileSet.name; 80 | } 81 | if (displayName !== undefined) { 82 | try { 83 | await intent.setDisplayName(displayName); 84 | matrixUser.set("displayname", displayName); 85 | } catch (e) { 86 | log.error("Failed to set display_name for user:", e); 87 | errors.push(e); 88 | } 89 | } 90 | } 91 | 92 | if (remoteProfileSet.avatar_uri && matrixUser.get("avatar_url") !== remoteProfileSet.avatar_uri) { 93 | log.debug(`Got an avatar, setting`); 94 | try { 95 | const {type, data} = await account.getAvatarBuffer(remoteProfileSet.avatar_uri, senderId); 96 | const mxcUrl = await intent.uploadContent(data, { 97 | name: path.basename(remoteProfileSet.avatar_uri), 98 | type, 99 | }); 100 | await intent.setAvatarUrl(mxcUrl); 101 | matrixUser.set("avatar_url", remoteProfileSet.avatar_uri); 102 | } catch (e) { 103 | log.error("Failed to update avatar_url for user:", e); 104 | errors.push(e); 105 | } 106 | } 107 | await this.store.setMatrixUser(matrixUser); 108 | 109 | if (errors.length) { 110 | throw new AggregateError(errors, "Failed to fully update profile for user"); 111 | } 112 | } 113 | 114 | private async getOrCreateStoreUsers(protocol: BifrostProtocol, senderId: string) 115 | : Promise<{matrixUser: MatrixUser, remoteUser: BifrostRemoteUser}> { 116 | const userId: string = protocol.getMxIdForProtocol( 117 | senderId, 118 | this.config.bridge.domain, 119 | this.config.bridge.userPrefix, 120 | ).getId(); 121 | let mxUser = await this.store.getMatrixUser(userId); 122 | 123 | let remoteUsers: BifrostRemoteUser[] | never[]; 124 | if (mxUser != null) { 125 | remoteUsers = await this.store.getRemoteUsersFromMxId(userId); 126 | } else { 127 | remoteUsers = []; 128 | mxUser = new MatrixUser(userId); 129 | } 130 | 131 | if (remoteUsers.length === 0) { 132 | const {remote, matrix} = await this.store.storeGhost(userId, protocol, senderId); 133 | return {matrixUser: matrix, remoteUser: remote}; 134 | } 135 | return {matrixUser: mxUser, remoteUser: remoteUsers[0]}; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/ProtoHacks.ts: -------------------------------------------------------------------------------- 1 | import { IChatInvite, IChatJoined, IChatJoinProperties } from "./bifrost/Events"; 2 | import { BifrostProtocol } from "./bifrost/Protocol"; 3 | import { Intent, Logger } from "matrix-appservice-bridge"; 4 | import { IBifrostAccount } from "./bifrost/Account"; 5 | 6 | const log = new Logger("ProtoHacks"); 7 | 8 | export const PRPL_MATRIX = "prpl-matrix"; 9 | export const PRPL_XMPP = "prpl-jabber"; 10 | export const PRPL_S4B = "prpl-sipe"; 11 | export const XMPP_JS = "xmpp-js"; 12 | 13 | /** 14 | * This class hacks around issues with certain protocols when interloping with 15 | * Matrix. The author kindly asks you to take care and document these functions 16 | * carefully so that future folks can understand what is going on. 17 | */ 18 | export class ProtoHacks { 19 | public static async addJoinProps(protocolId: string, props: { handle?: string }, userId: string, intent: Intent|string) { 20 | // When joining XMPP rooms, we should set a handle so pull off one from the users 21 | // profile. 22 | if (protocolId === PRPL_XMPP || protocolId === XMPP_JS) { 23 | try { 24 | if (typeof(intent) === "string") { 25 | props.handle = intent; 26 | } else { 27 | props.handle = (await intent.getProfileInfo(userId)).displayname; 28 | } 29 | } catch (ex) { 30 | log.warn("Failed to get profile for", userId); 31 | props.handle = userId; 32 | } 33 | } 34 | } 35 | 36 | public static removeSensitiveJoinProps(protocolId: string, props: { handle?: string }) { 37 | // XXX: We *don't* currently drop passwords to groups which leaves them 38 | // exposed in the room-store. Please be careful. 39 | if (protocolId === PRPL_XMPP || protocolId === XMPP_JS) { 40 | // Handles are like room nicks, so obviously don't store it. 41 | delete props.handle; 42 | } 43 | } 44 | 45 | public static getRoomNameFromProps(protocolId: string, props: IChatJoinProperties): string | undefined { 46 | if (protocolId === XMPP_JS) { 47 | return `${props.room}@${props.server}`; 48 | } 49 | } 50 | 51 | public static getRoomNameForInvite(invite: IChatInvite|IChatJoined): string { 52 | // prpl-matrix sends us an invite with the room name set to the 53 | // matrix user's displayname, but the real room name is the room_id. 54 | if (invite.account.protocol_id === PRPL_MATRIX) { 55 | return invite.join_properties.room_id; 56 | } 57 | if ("conv" in invite) { 58 | return invite.conv.name; 59 | } 60 | return invite.room_name; 61 | } 62 | 63 | public static getSenderIdToLookup(protocol: BifrostProtocol, senderId: string, chatName: string) { 64 | // If this is an XMPP MUC, we want to append the chatname to the user. 65 | if (protocol.id === PRPL_XMPP && chatName) { 66 | return `${chatName}/${senderId}`; 67 | } 68 | return senderId; 69 | } 70 | 71 | public static getSenderId(account: IBifrostAccount, senderId: string, roomName?: string): string { 72 | // XXX: XMPP uses "handles" in group chats which might not be the same as 73 | // the username. 74 | if (account.protocol.id === PRPL_XMPP && roomName) { 75 | return account.getJoinPropertyForRoom(roomName, "handle") || senderId; 76 | } 77 | return senderId; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/RoomAliasSet.ts: -------------------------------------------------------------------------------- 1 | import { IConfigPortals } from "./Config"; 2 | import { BifrostProtocol } from "./bifrost/Protocol"; 3 | import { IChatJoinProperties } from "./bifrost/Events"; 4 | import { IBifrostInstance } from "./bifrost/Instance"; 5 | import { Logger } from "matrix-appservice-bridge"; 6 | const log = new Logger("RoomAliasSet"); 7 | 8 | export interface IRoomAlias { 9 | protocol: string; 10 | properties: {[key: string]: string}; 11 | } 12 | 13 | export interface IAliasResult { 14 | protocol: BifrostProtocol; 15 | properties: IChatJoinProperties; 16 | } 17 | 18 | export class RoomAliasSet { 19 | private aliases: Map; 20 | 21 | constructor(config: IConfigPortals, private purple: IBifrostInstance) { 22 | config.aliases = config.aliases || {}; 23 | this.aliases = new Map(); 24 | Object.keys(config.aliases).forEach((regex) => { 25 | this.aliases.set(new RegExp(regex), config.aliases![regex]); 26 | }); 27 | log.info(`Loaded ${this.aliases.size} regexes`); 28 | } 29 | 30 | public getOptsForAlias(alias: string): IAliasResult | undefined { 31 | log.info("Checking alias", alias); 32 | for (const regex of this.aliases.keys()) { 33 | const match = regex.exec(alias); 34 | if (!match) { 35 | log.debug(`No match for ${alias} against ${regex}`); 36 | continue; 37 | } 38 | const opts = this.aliases.get(regex)!; 39 | const protocol = this.purple.getProtocol(opts.protocol)!; 40 | if (!protocol) { 41 | log.warn(`${alias} matched ${opts.protocol} but no protocol is available`); 42 | return; 43 | } 44 | const properties = Object.assign({}, opts.properties); 45 | Object.keys(properties).forEach((key) => { 46 | const split = properties[key].split("regex:"); 47 | if (split.length === 2) { 48 | properties[key] = match[parseInt(split[1])]; 49 | } 50 | }); 51 | return {protocol, properties}; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/RoomSync.ts: -------------------------------------------------------------------------------- 1 | import { AppServiceBot, Intent, Logger, RemoteRoom } from "matrix-appservice-bridge"; 2 | import { IBifrostInstance } from "./bifrost/Instance"; 3 | import { IAccountEvent, IChatJoinProperties } from "./bifrost/Events"; 4 | import { IStore } from "./store/Store"; 5 | import { MROOM_TYPE_GROUP } from "./store/Types"; 6 | import { Util } from "./Util"; 7 | import { Deduplicator } from "./Deduplicator"; 8 | import { ProtoHacks } from "./ProtoHacks"; 9 | import { GatewayHandler } from "./GatewayHandler"; 10 | 11 | interface IRoomMembership { 12 | room_name: string; 13 | params: IChatJoinProperties; 14 | membership: "join"|"leave"; 15 | } 16 | 17 | const SYNC_RETRY_MS = 100; 18 | const MAX_SYNCS = 8; 19 | const JOINLEAVE_TIMEOUT = 60000; 20 | const log = new Logger("RoomSync"); 21 | 22 | export class RoomSync { 23 | private accountRoomMemberships: Map; 24 | private ongoingSyncs = 0; 25 | constructor( 26 | private bifrost: IBifrostInstance, 27 | private store: IStore, 28 | private deduplicator: Deduplicator, 29 | private gateway: GatewayHandler, 30 | private intent: Intent, 31 | ) { 32 | this.accountRoomMemberships = new Map(); 33 | this.bifrost.on("account-signed-on", this.onAccountSignedin.bind(this)); 34 | } 35 | 36 | public getMembershipForUser(user: string): IRoomMembership[]|undefined { 37 | return this.accountRoomMemberships.get(user); 38 | } 39 | 40 | public async sync(bot: AppServiceBot) { 41 | log.info("Beginning sync"); 42 | try { 43 | await this.syncAccountsToGroupRooms(bot); 44 | } catch (err) { 45 | log.error("Caugh error while trying to sync group rooms", err); 46 | throw Error("Encountered error while syncing. Cannot continue"); 47 | } 48 | log.info("Finished sync"); 49 | } 50 | 51 | private async getJoinedMembers(bot: AppServiceBot, roomId: string) { 52 | while (this.ongoingSyncs >= MAX_SYNCS) { 53 | await new Promise((resolve) => setTimeout(resolve, 100)); 54 | } 55 | this.ongoingSyncs++; 56 | log.debug(`Syncing members for ${roomId} (${this.ongoingSyncs}/${MAX_SYNCS})`); 57 | while (true) { 58 | try { 59 | const result = await bot.getJoinedMembers(roomId); 60 | this.ongoingSyncs--; 61 | return result; 62 | } catch (ex) { 63 | if (ex.errcode === "M_FORBIDDEN") { 64 | throw Error("Got M_FORBIDDEN while trying to sync members for room."); 65 | } 66 | log.warn(`Failed to get joined members for ${roomId}, retrying in ${SYNC_RETRY_MS}`, ex); 67 | await new Promise((resolve) => setTimeout(resolve, SYNC_RETRY_MS)); 68 | } 69 | } 70 | } 71 | 72 | /** 73 | * This function will collect all the members of the group rooms and decide 74 | * if the backend side needs to "reconnect" to the rooms. 75 | * 76 | * @return [description] 77 | */ 78 | private async syncAccountsToGroupRooms(bot: AppServiceBot): Promise { 79 | const rooms = await this.store.getRoomsOfType(MROOM_TYPE_GROUP); 80 | log.info(`Got ${rooms.length} group rooms`); 81 | await Promise.all(rooms.map(async (room) => { 82 | if (!room.matrix) { 83 | log.warn(`Not syncing entry because it has no matrix component`); 84 | return; 85 | } 86 | const roomId = room.matrix.getId(); 87 | if (!room.remote) { 88 | log.warn(`Not syncing ${roomId} because it has no remote links`); 89 | return; 90 | } 91 | const protocolId = room.remote.get("protocol_id"); 92 | if (!this.bifrost.getProtocol(protocolId)) { 93 | log.debug(`Not syncing ${roomId} because the purple backend doesn't support this protocol`); 94 | return; 95 | } 96 | const isGateway = room.remote.get("gateway"); 97 | if (isGateway) { 98 | // The gateway handler syncs via roomState. 99 | try { 100 | await this.gateway.initialMembershipSync(room); 101 | } catch (ex) { 102 | log.warn(`Failed to sync gateway membership for room ${roomId}`); 103 | } 104 | return; 105 | } 106 | let members: {[userId: string]: {display_name?: string}}; 107 | try { 108 | members = await this.getJoinedMembers(bot, roomId); 109 | } catch (ex) { 110 | log.warn(`Not syncing ${roomId} because we could not get room members: ${ex}`); 111 | return; 112 | } 113 | const userIds = Object.keys(members); 114 | for (const userId of userIds) { 115 | // Never sync the bot user. 116 | if (bot.getUserId() === userId) { 117 | continue; 118 | } 119 | if (!bot.isRemoteUser(userId)) { 120 | await this.syncMatrixUser(userId, roomId, room.remote, members[userId].display_name); 121 | } 122 | } 123 | })); 124 | } 125 | 126 | private async syncMatrixUser(userId: string, roomId: string, remoteRoom: RemoteRoom, 127 | displayName: string) { 128 | log.debug(`Syncing matrix ${userId} -> ${roomId}`); 129 | 130 | // First get an account for this matrix user. 131 | const protocolId = remoteRoom.get("protocol_id"); 132 | const remotes = await this.store.getAccountsForMatrixUser(userId, protocolId); 133 | if (remotes.length === 0) { 134 | log.warn(`${userId} has no remote accounts matching the rooms protocol`); 135 | return; 136 | } 137 | const remoteUser = remotes[0]; 138 | log.info(`${remoteUser.id} will join ${remoteRoom.get("room_name")} on connection`); 139 | 140 | // Get properties needed to join the room 141 | const props = Util.desanitizeProperties(Object.assign({}, remoteRoom.get("properties"))); 142 | // Set some extra information about the user that is dynamic, e.g. handle. 143 | await ProtoHacks.addJoinProps( 144 | protocolId, props, userId, displayName || this.intent, 145 | ); 146 | const acctMemberList = this.accountRoomMemberships.get(remoteUser.id) || []; 147 | acctMemberList.push({ 148 | room_name: remoteRoom.get("room_name"), 149 | params: props, 150 | membership: "join", 151 | }); 152 | this.accountRoomMemberships.set(remoteUser.id, acctMemberList); 153 | } 154 | 155 | private async onAccountSignedin(ev: IAccountEvent) { 156 | log.info(`${ev.account.username} signed in, checking if we need to reconnect them to some rooms`); 157 | const matrixUser = await this.store.getMatrixUserForAccount(ev.account); 158 | const acct = this.bifrost.getAccount( 159 | ev.account.username, 160 | ev.account.protocol_id, 161 | matrixUser ? matrixUser.getId() : undefined, 162 | ); 163 | if (matrixUser === null) { 164 | log.warn(`${ev.account.username} isn't bound to a matrix account. Disabling`); 165 | try { 166 | if (acct !== null) { 167 | acct.setEnabled(false); 168 | return; 169 | } 170 | } catch (ex) { 171 | // This can fail. 172 | } 173 | 174 | log.error(`Tried to get ${ev.account.username} but the backend found nothing. This shouldn't happen!`); 175 | return; 176 | } 177 | const remoteId = Util.createRemoteId(ev.account.protocol_id, ev.account.username); 178 | const reconnectStack = this.accountRoomMemberships.get(remoteId) || []; 179 | const reconsToMake = reconnectStack.length; 180 | log.debug(`Found ${reconsToMake} reconnections to be made for ${remoteId} (${matrixUser.getId()})`); 181 | let membership: IRoomMembership|undefined; 182 | membership = reconnectStack.pop(); 183 | let i = 0; 184 | while (membership) { 185 | i++; 186 | try { 187 | if (membership.membership === "join") { 188 | log.info(`${i}/${reconsToMake} JOIN ${remoteId} -> ${membership.room_name}`); 189 | await acct.joinChat(membership.params, this.bifrost, JOINLEAVE_TIMEOUT, false); 190 | acct.setJoinPropertiesForRoom?.(membership.room_name, membership.params); 191 | } else { 192 | log.info(`${i}/${reconsToMake} LEAVE ${remoteId} -> ${membership.room_name}`); 193 | await acct!.rejectChat(membership.params); 194 | this.deduplicator.decrementRoomUsers(membership.room_name); 195 | } 196 | } catch (ex) { 197 | log.warn(`Failed to ${membership.membership} ${remoteId} to ${membership.room_name}:`, ex); 198 | // XXX: Retry behaviour? 199 | } 200 | membership = reconnectStack.pop(); 201 | } 202 | this.accountRoomMemberships.delete(remoteId); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/Util.ts: -------------------------------------------------------------------------------- 1 | import { IChatJoinProperties } from "./bifrost/Events"; 2 | import { Intent, WeakEvent } from "matrix-appservice-bridge"; 3 | import * as crypto from "crypto"; 4 | 5 | export class Util { 6 | 7 | public static MINUTE_MS = 60000; 8 | 9 | public static createRemoteId(protocol: string, id: string): string { 10 | return `${protocol}://${id}`; 11 | } 12 | 13 | public static passwordGen(minLength: number = 32): string { 14 | let password = ""; 15 | while (password.length < minLength) { 16 | // must be printable 17 | for (const char of crypto.randomBytes(32)) { 18 | if (char >= 32 && char <= 126) { 19 | password += String.fromCharCode(char); 20 | } 21 | } 22 | } 23 | return password; 24 | } 25 | 26 | public static sanitizeProperties(props: IChatJoinProperties): IChatJoinProperties { 27 | for (const k of Object.keys(props)) { 28 | const value = props[k]; 29 | const newkey = k.replace(/\./g, "·"); 30 | delete props[k]; 31 | props[newkey] = value; 32 | } 33 | return props; 34 | } 35 | 36 | public static desanitizeProperties(props: IChatJoinProperties): IChatJoinProperties { 37 | for (const k of Object.keys(props)) { 38 | const value = props[k]; 39 | const newkey = k.replace(/·/g, "."); 40 | delete props[k]; 41 | props[newkey] = value; 42 | } 43 | return props; 44 | } 45 | 46 | public static unescapeUserId(userId: string): string { 47 | return userId.replace(/(=[0-9a-z]{2})/g, (code) => 48 | String.fromCharCode(parseInt(code.substr(1), 16)), 49 | ); 50 | } 51 | 52 | public static async getMessagesBeforeJoin( 53 | intent: Intent, roomId: string): Promise { 54 | const client = intent.matrixClient; 55 | const { chunk } = await client.doRequest("GET", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages`, {dir: 'b'}); 56 | // eslint-disable-next-line no-underscore-dangle 57 | const msgs: WeakEvent[] = []; 58 | for (const msg of chunk.reverse()) { 59 | // This is our membership 60 | if (msg.type === "m.room.member" && msg.sender === client.getUserId()) { 61 | break; 62 | } 63 | if (msg.type === "m.room.message") { 64 | msgs.push(msg); 65 | } 66 | } 67 | return msgs; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/bifrost/Account.ts: -------------------------------------------------------------------------------- 1 | import { IChatJoinProperties, IUserInfo, IConversationEvent } from "./Events"; 2 | import { IBifrostInstance } from "./Instance"; 3 | import { BifrostProtocol } from "./Protocol"; 4 | import { IBasicProtocolMessage } from "../MessageFormatter"; 5 | 6 | export interface IChatJoinOptions { 7 | identifier: string; 8 | label: string; 9 | required: boolean; 10 | } 11 | 12 | export interface IProfileProvider { 13 | getUserInfo(who: string): Promise; 14 | getBuddy?(user: string): any|undefined; 15 | getAvatarBuffer(uri: string, senderId: string): Promise<{type: string, data: Buffer}>; 16 | } 17 | 18 | export type IAccountExtraConfig = Record; 19 | export interface IBifrostAccount extends IProfileProvider { 20 | remoteId: string; 21 | name: string; 22 | isEnabled: boolean; 23 | connected: boolean; 24 | protocol: BifrostProtocol; 25 | 26 | createNew(password?: string, extraConfig?: IAccountExtraConfig); 27 | setEnabled(enable: boolean); 28 | sendIM(recipient: string, body: IBasicProtocolMessage); 29 | sendIMTyping(recipient: string, isTyping: boolean); 30 | sendChat(chatName: string, body: IBasicProtocolMessage); 31 | getBuddy?(user: string): any|undefined; 32 | getJoinPropertyForRoom?(roomName: string, key: string): string|undefined; 33 | setJoinPropertiesForRoom?(roomName: string, props: IChatJoinProperties); 34 | isInRoom(roomName: string): boolean; 35 | joinChat( 36 | components: IChatJoinProperties, 37 | purple?: IBifrostInstance, 38 | timeout?: number, 39 | setWaiting?: boolean) 40 | : Promise; 41 | 42 | rejectChat(components: IChatJoinProperties); 43 | getConversation?(name: string): any|undefined; 44 | getChatParamsForProtocol(): IChatJoinOptions[]; 45 | setStatus(statusId: string, active: boolean); 46 | // TODO: Is setStatus the same thing? 47 | setPresence?(content: { currently_active?: boolean; last_active_ago?: number; presence: "online" | "offline" | "unavailable"; status_msg?: string; }, recipients?: string[]); 48 | } 49 | -------------------------------------------------------------------------------- /src/bifrost/Events.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A collection of interfaces that may be emitted by PurpleInterface 3 | */ 4 | 5 | import { IBifrostAccount } from "./Account"; 6 | import { IBasicProtocolMessage } from "../MessageFormatter"; 7 | import { IPublicRoomsResponse } from "../MatrixTypes"; 8 | 9 | export interface IChatJoinProperties {[key: string]: string; } 10 | 11 | export interface IEventBody { 12 | eventName: string; 13 | } 14 | 15 | export interface IAccountMinimal { 16 | protocol_id: string; 17 | username: string; 18 | } 19 | 20 | export interface IConversationMinimal { 21 | name: string; 22 | } 23 | 24 | export interface IAccountEvent extends IEventBody { 25 | account: any|IAccountMinimal; 26 | mxid?: string; 27 | } 28 | 29 | export interface IConversationEvent extends IAccountEvent { 30 | conv: any | IConversationMinimal; 31 | } 32 | 33 | // received-im-msg 34 | export interface IReceivedImMsg extends IConversationEvent { 35 | sender: string; 36 | message: IBasicProtocolMessage; 37 | } 38 | 39 | export interface IChatInvite extends IAccountEvent { 40 | sender: string; 41 | message: string; 42 | room_name: string; 43 | join_properties: IChatJoinProperties; 44 | } 45 | 46 | export interface IChatJoined extends IConversationEvent { 47 | purpleAccount: IBifrostAccount; 48 | join_properties: IChatJoinProperties; 49 | should_invite: boolean; 50 | } 51 | 52 | export interface IUserStateChanged extends IConversationEvent { 53 | sender: string; 54 | state: "joined"|"left"|"kick"; 55 | kicker: string|undefined; 56 | reason?: string; 57 | gatewayAlias: string|null; 58 | id: string; 59 | } 60 | 61 | export interface IChatTopicState extends IConversationEvent { 62 | sender: string; 63 | topic: string; 64 | } 65 | 66 | export interface IUserInfo extends IAccountEvent { 67 | [key: string]: string|IAccountMinimal|undefined; 68 | who: string; 69 | } 70 | 71 | export interface IChatTyping extends IConversationEvent { 72 | sender: string; 73 | typing: boolean; 74 | } 75 | 76 | export interface IChatReadReceipt extends IConversationEvent { 77 | sender: string; 78 | messageId: string; 79 | originIsMatrix: boolean; 80 | } 81 | 82 | export interface IGatewayRequest { 83 | roomAlias: string; 84 | result: (err: Error|null, res?: any) => void; 85 | } 86 | 87 | export interface IGatewayRoomQuery extends IGatewayRequest { 88 | result: (err: Error|null, res?: string) => void; 89 | } 90 | 91 | export interface IGatewayPublicRoomsQuery extends IGatewayRequest { 92 | onlyCheck: boolean; 93 | searchString: string; 94 | homeserver: string|null; 95 | result: (err: Error|null, res?: IPublicRoomsResponse) => void; 96 | } 97 | 98 | export interface IGatewayJoin { 99 | sender: string; 100 | protocol_id: string; 101 | join_id: string; 102 | nick: string; 103 | roomAlias: string; 104 | room_name: string; 105 | } 106 | 107 | export interface IStoreRemoteUser { 108 | mxId: string; 109 | remoteId: string; 110 | protocol_id: string; 111 | data?: any; 112 | } 113 | 114 | export interface IContactListSubscribeRequest extends IAccountEvent { 115 | sender: string; 116 | cb: (accept: boolean) => Promise; 117 | } -------------------------------------------------------------------------------- /src/bifrost/Gateway.ts: -------------------------------------------------------------------------------- 1 | import { MatrixMembershipEvent } from "../MatrixTypes"; 2 | import { IBasicProtocolMessage } from "../MessageFormatter"; 3 | import { BifrostRemoteUser } from "../store/BifrostRemoteUser"; 4 | import { IProfileProvider } from "./Account"; 5 | 6 | export interface IGateway extends IProfileProvider { 7 | sendMatrixMessage( 8 | chatName: string, 9 | sender: string, body: IBasicProtocolMessage, room: IGatewayRoom, 10 | ): void; 11 | sendMatrixMembership( 12 | chatName: string, event: MatrixMembershipEvent, room: IGatewayRoom, 13 | ): void; 14 | sendStateChange( 15 | chatName: string, sender: string, type: "topic"|"name"|"avatar", room: IGatewayRoom, 16 | ): void; 17 | onRemoteJoin(err: string|null, joinId: string, room: IGatewayRoom|undefined, ownMxid: string|undefined, 18 | ): Promise; 19 | initialMembershipSync(chatName: string, room: IGatewayRoom, remoteGhosts: BifrostRemoteUser[]): void; 20 | getMxidForRemote(sender: string): string; 21 | memberInRoom(chatName: string, matrixId: string): boolean; 22 | } 23 | 24 | export interface IGatewayRoom { 25 | name: string; 26 | topic: string; 27 | avatar?: string; 28 | roomId: string; 29 | membership: { 30 | sender: string; 31 | stateKey: string; 32 | displayname?: string; 33 | membership: string; 34 | isRemote: boolean; 35 | }[]; 36 | // remotes: string[]; 37 | } 38 | -------------------------------------------------------------------------------- /src/bifrost/Instance.ts: -------------------------------------------------------------------------------- 1 | import { BifrostProtocol } from "./Protocol"; 2 | import { IBifrostAccount } from "./Account"; 3 | import { IEventBody, 4 | IAccountEvent, 5 | IChatJoined, 6 | IConversationEvent, 7 | IUserStateChanged, 8 | IChatTopicState, 9 | IChatInvite, 10 | IReceivedImMsg, 11 | IChatTyping, 12 | IGatewayJoin, 13 | IGatewayRoomQuery, 14 | IStoreRemoteUser, 15 | IChatReadReceipt, 16 | IGatewayPublicRoomsQuery, 17 | IChatJoinProperties, 18 | } from "./Events"; 19 | import { EventEmitter } from "node:events"; 20 | import { IGateway } from "./Gateway"; 21 | import { AutoRegistration } from "../AutoRegistration"; 22 | 23 | export interface IBifrostInstance extends EventEmitter { 24 | gateway: IGateway|null; 25 | createBifrostAccount(username, protocol: BifrostProtocol): IBifrostAccount; 26 | preStart?(autoReg: AutoRegistration): void; 27 | start(): Promise; 28 | close(): Promise; 29 | getAccount(username: string, protocolId: string, mxid?: string): IBifrostAccount|null; 30 | getProtocol(id: string): BifrostProtocol|undefined; 31 | getProtocols(): BifrostProtocol[]; 32 | findProtocol(nameOrId: string): BifrostProtocol|undefined; 33 | getNickForChat?(conv: any): string; 34 | getUsernameFromMxid(mxid: string, prefix: string): {username: string, protocol: BifrostProtocol}; 35 | checkGroupExists(properties: IChatJoinProperties, protocol: BifrostProtocol): Promise; 36 | on(name: string, cb: (ev: IEventBody) => void); 37 | on( 38 | name: "account-connection-error"|"account-signed-on"|"account-signed-off", 39 | cb: (ev: IAccountEvent) => void, 40 | ); 41 | on(name: "chat-joined", cb: (ev: IConversationEvent) => void); 42 | on(name: "chat-joined-new", cb: (ev: IChatJoined) => void); 43 | on(name: "chat-user-joined"|"chat-user-left"|"chat-user-kick"|"chat-kick", cb: (ev: IUserStateChanged) => void); 44 | on(name: "chat-topic", cb: (ev: IChatTopicState) => void); 45 | on(name: "chat-invite", cb: (ev: IChatInvite) => void); 46 | on(name: "gateway-queryroom", cb: (ev: IGatewayRoomQuery) => void); 47 | on(name: "gateway-joinroom", cb: (ev: IGatewayJoin) => void); 48 | on(name: "gateway-publicrooms", cb: (ev: IGatewayPublicRoomsQuery) => void); 49 | on(name: "chat-typing"|"im-typing", cb: (ev: IChatTyping) => void); 50 | on(name: "received-im-msg"|"received-chat-msg", cb: (ev: IReceivedImMsg) => void); 51 | on(name: "store-remote-user", cb: (ev: IStoreRemoteUser) => void); 52 | on(name: "read-receipt", cb: (ev: IChatReadReceipt) => void); 53 | eventAck(eventName: string, data: IEventBody); 54 | 55 | needsDedupe(): boolean; 56 | needsAccountLock(): boolean; 57 | usingSingleProtocol(): string|undefined; 58 | // getMXIDForSender(sender: string, domain: string, prefix: string, protocol: Protocol); 59 | // parseMxIdForSender(mxid: string): {protocol: BifrostProtocol, sender: string}; 60 | } 61 | -------------------------------------------------------------------------------- /src/bifrost/Protocol.ts: -------------------------------------------------------------------------------- 1 | import { MatrixUser } from "matrix-appservice-bridge"; 2 | 3 | export abstract class BifrostProtocol { 4 | public readonly id: string; 5 | public readonly name: string; 6 | public readonly summary?: string; 7 | public readonly homepage?: string; 8 | constructor( 9 | data: { name: string, summary?: string, homepage?: string, id: string}, 10 | public readonly canAddExisting: boolean = true, 11 | public readonly canCreateNew: boolean = true, 12 | ) { 13 | this.name = data.name; 14 | this.summary = data.summary; 15 | this.homepage = data.homepage; 16 | this.id = data.id; 17 | } 18 | 19 | public abstract getMxIdForProtocol( 20 | senderId: string, 21 | domain: string, 22 | prefix?: string): MatrixUser 23 | } 24 | -------------------------------------------------------------------------------- /src/generate-signing-key.js: -------------------------------------------------------------------------------- 1 | const webcrypto = require('node:crypto'); 2 | 3 | async function main() { 4 | const key = await webcrypto.subtle.generateKey({ 5 | name: 'HMAC', 6 | hash: 'SHA-512', 7 | }, true, ['sign', 'verify']); 8 | console.log(JSON.stringify(await webcrypto.subtle.exportKey('jwk', key), undefined, 4)); 9 | } 10 | 11 | main().then(() => process.exit(0)).catch(err => { throw err }); 12 | -------------------------------------------------------------------------------- /src/purple/PurpleAccount.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An interface for storing account data inside the userstore. 3 | */ 4 | 5 | // @ts-ignore - These are optional. 6 | import { buddy, accounts, messaging, Buddy, Account, Conversation, notify, TypingState } from "node-purple"; 7 | import { promises as fs } from "fs"; 8 | import { Logger } from "matrix-appservice-bridge"; 9 | import { BifrostProtocol } from "../bifrost/Protocol"; 10 | import { IChatJoinProperties, IUserInfo, IConversationEvent } from "../bifrost/Events"; 11 | import { IBasicProtocolMessage } from "../MessageFormatter"; 12 | import { Util } from "../Util"; 13 | import { IBifrostInstance } from "../bifrost/Instance"; 14 | import { IBifrostAccount } from "../bifrost/Account"; 15 | 16 | const log = new Logger("PurpleAccount"); 17 | 18 | export type PurpleAccountHandle = unknown; 19 | 20 | export interface IChatJoinOptions { 21 | identifier: string; 22 | label: string; 23 | required: boolean; 24 | } 25 | 26 | export class PurpleAccount implements IBifrostAccount { 27 | private acctData: Account; 28 | private enabled: boolean; 29 | private waitingJoinRoomProperties: IChatJoinProperties | undefined; 30 | private joinPropertiesForRooms: Map; 31 | private userAccountInfoPromises: Map void>; 32 | constructor(private username: string, public readonly protocol: BifrostProtocol) { 33 | this.enabled = false; 34 | this.userAccountInfoPromises = new Map(); 35 | this.joinPropertiesForRooms = new Map(); 36 | // Find the account 37 | const data = accounts.find(this.username, this.protocol.id); 38 | if (!data) { 39 | throw new Error("Account not found"); 40 | } 41 | this.acctData = data; 42 | this.enabled = accounts.get_enabled(this.acctData.handle); 43 | } 44 | 45 | get waitingJoinRoomProps(): IChatJoinProperties|undefined { return this.waitingJoinRoomProperties; } 46 | 47 | get remoteId(): string { return Util.createRemoteId(this.protocol.id, this.username); } 48 | 49 | get name(): string { return this.acctData!.username; } 50 | 51 | get handle(): PurpleAccountHandle { return this.acctData!.handle; } 52 | 53 | get isEnabled(): boolean { return this.enabled; } 54 | 55 | get connected(): boolean { 56 | return accounts.is_connected(this.acctData.handle); 57 | } 58 | 59 | public createNew(password?: string, extraConfig?: Record) { 60 | this.acctData = accounts.new(this.username, this.protocol.id, password); 61 | accounts.configure(this.acctData.handle, extraConfig); 62 | } 63 | 64 | public setEnabled(enable: boolean) { 65 | accounts.set_enabled(this.acctData!.handle, enable); 66 | this.enabled = enable; 67 | } 68 | 69 | public sendIM(recipient: string, msg: IBasicProtocolMessage) { 70 | let body = msg.body; 71 | 72 | if (msg.opts?.attachments) { 73 | body += " " + msg.opts.attachments.map(a => 'uri' in a ? a.uri : null).filter(a => !!a).join(', '); 74 | } 75 | 76 | messaging.sendIM(this.handle, recipient, body); 77 | } 78 | 79 | public sendIMTyping(recipient: string, isTyping: boolean) { 80 | messaging.setIMTypingState(this.handle, recipient, isTyping ? 1 : 0); 81 | } 82 | 83 | public sendChat(chatName: string, msg: IBasicProtocolMessage) { 84 | messaging.sendChat(this.handle, chatName, msg.body); 85 | } 86 | 87 | public getBuddy(user: string): Buddy { 88 | return buddy.find(this.handle, user); 89 | } 90 | 91 | public getJoinPropertyForRoom(roomName: string, key: string): string|undefined { 92 | const props = this.joinPropertiesForRooms.get(roomName); 93 | if (props) { 94 | return props[key]; 95 | } 96 | } 97 | 98 | public setJoinPropertiesForRoom(roomName: string, props: IChatJoinProperties) { 99 | // We kinda need to know the join properties for things like XMPP's handle. 100 | this.joinPropertiesForRooms.set(roomName, props); 101 | } 102 | 103 | public isInRoom(roomName: string) { 104 | return this.joinPropertiesForRooms.has(roomName); 105 | } 106 | 107 | public joinChat( 108 | components: IChatJoinProperties, 109 | purple?: IBifrostInstance, 110 | timeout: number = 1000, 111 | setWaiting: boolean = true) 112 | : Promise { 113 | // XXX: This is extremely bad, but there isn't a way to map "join_properties" of a join 114 | // room request to the joined-room event, which throws us off quite badly. 115 | if (setWaiting) { 116 | this.waitingJoinRoomProperties = components; 117 | } 118 | messaging.joinChat(this.handle, components); 119 | if (purple) { 120 | return new Promise((resolve, reject) => { 121 | const timer = setTimeout(reject, timeout); 122 | let cb = null; 123 | cb = (ev: IConversationEvent) => { 124 | if (ev.account.username === this.username && ev.account.protocol_id === this.protocol.id) { 125 | resolve(ev); 126 | purple.removeListener("chat-joined", cb); 127 | clearTimeout(timer); 128 | } 129 | }; 130 | purple.on("chat-joined", cb); 131 | }); 132 | } 133 | return Promise.resolve(); 134 | } 135 | 136 | public rejectChat(components: IChatJoinProperties) { 137 | messaging.rejectChat(this.handle, components); 138 | } 139 | 140 | public getConversation(name: string): Conversation { 141 | return messaging.findConversation(this.handle, name); 142 | } 143 | 144 | public passUserInfoResponse(uinfo: IUserInfo) { 145 | const resolve = this.userAccountInfoPromises.get(uinfo.who); 146 | if (resolve) { 147 | resolve(uinfo); 148 | } 149 | } 150 | 151 | public getChatParamsForProtocol(): IChatJoinOptions[] { 152 | return messaging.chatParams(this.handle, this.protocol.id); 153 | } 154 | 155 | public getUserInfo(who: string): Promise { 156 | notify.get_user_info(this.handle, who); 157 | return new Promise ((resolve, reject) => { 158 | const id = setTimeout(reject, 10000); 159 | this.userAccountInfoPromises.set(who, (info) => { 160 | clearTimeout(id); 161 | resolve(info); 162 | }); 163 | }); 164 | } 165 | 166 | public eraseWaitingJoinRoomProps() { 167 | this.waitingJoinRoomProperties = undefined; 168 | } 169 | 170 | public async getAvatarBuffer(iconPath: string): Promise<{type: string, data: Buffer}> { 171 | return { 172 | type: "image/jpeg", 173 | data: await fs.readFile(iconPath), 174 | }; 175 | } 176 | 177 | public setStatus(statusId: string, active: boolean) { 178 | accounts.set_status(this.handle, statusId, active); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/purple/PurpleInstance.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore - These are optional. 2 | import { helper, plugins, messaging, Conversation } from "node-purple"; 3 | import { EventEmitter } from "events"; 4 | import { PurpleAccount } from "./PurpleAccount"; 5 | import { IBifrostInstance } from "../bifrost/Instance"; 6 | import { Logger } from "matrix-appservice-bridge"; 7 | import * as path from "path"; 8 | import { IConfigPurple } from "../Config"; 9 | import { IUserInfo, IConversationEvent } from "../bifrost/Events"; 10 | import { BifrostProtocol } from "../bifrost/Protocol"; 11 | import { promises as fs } from "fs"; 12 | import { PurpleProtocol } from "./PurpleProtocol"; 13 | 14 | const log = new Logger("PurpleInstance"); 15 | 16 | const DEFAULT_PLUGIN_DIR = "/usr/lib/purple-2"; 17 | export interface IPurpleBackendOpts { 18 | debugEnabled: boolean; 19 | pluginDir: string; 20 | dataDir?: string; 21 | soloProtocol?: string; 22 | protocolOptions?: {[pluginName: string]: { 23 | // E.g. {0}@foo,FOO/{0} 24 | usernameFormat: string; 25 | }} 26 | } 27 | 28 | export class PurpleInstance extends EventEmitter implements IBifrostInstance { 29 | private protocols: BifrostProtocol[]; 30 | private accounts: Map; 31 | private interval?: NodeJS.Timeout; 32 | private backendOpts?: IPurpleBackendOpts; 33 | constructor(private config: IConfigPurple) { 34 | super(); 35 | this.backendOpts = this.config.backendOpts as IPurpleBackendOpts; 36 | this.protocols = []; 37 | this.accounts = new Map(); 38 | } 39 | 40 | public createBifrostAccount(username, protocol: BifrostProtocol) { 41 | // We might want to format this one. 42 | const protocolOptions = (this.backendOpts?.protocolOptions || {})[protocol.id]; 43 | if (protocolOptions?.usernameFormat) { 44 | // Replaces %-foo with username-foo 45 | username = protocolOptions.usernameFormat.replace(/\%/g, username); 46 | } 47 | return new PurpleAccount(username, protocol); 48 | } 49 | 50 | public async checkGroupExists() { 51 | // We don't check this, so just return true. 52 | return true; 53 | } 54 | 55 | public get gateway(): null { 56 | return null; // Not supported. 57 | } 58 | 59 | public usingSingleProtocol() { 60 | return this.backendOpts.soloProtocol; 61 | } 62 | 63 | public async start() { 64 | log.info("Starting purple instance"); 65 | const pluginDir = path.resolve(this.backendOpts?.pluginDir || DEFAULT_PLUGIN_DIR); 66 | try { 67 | await fs.access(pluginDir); 68 | } catch (ex) { 69 | throw Error( 70 | `Could not verify purple plugin directory "${pluginDir}" exists.` + 71 | "You may need to install libpurple plugins OR set the correct directory in your config.", 72 | ); 73 | } 74 | log.info("Plugin search path is set to", pluginDir); 75 | const userDir = this.backendOpts?.dataDir ? path.resolve(this.backendOpts.dataDir) : undefined; 76 | log.info("User directory is set to", userDir); 77 | helper.setupPurple({ 78 | debugEnabled: this.backendOpts?.debugEnabled ? 1 : 0, 79 | pluginDir, 80 | userDir, 81 | }); 82 | log.info("Started purple instance"); 83 | this.protocols = plugins.get_protocols().map( 84 | (data) => new PurpleProtocol(data, !!this.backendOpts.soloProtocol), 85 | ); 86 | log.info("Got supported protocols:", this.protocols.map((p) => p.id).join(" ")); 87 | if (this.backendOpts.soloProtocol) { 88 | log.info(`Using solo plugin ${this.backendOpts.soloProtocol}`); 89 | if (!this.getProtocol(this.backendOpts.soloProtocol)) { 90 | throw Error('Solo plugin defined but not in list of supported plugins') 91 | } 92 | } 93 | this.interval = setInterval(this.eventHandler.bind(this), 300); 94 | } 95 | 96 | public getAccount(username: string, protocolId: string, mxid: string, force: boolean = false): PurpleAccount { 97 | const key = `${protocolId}://${username}`; 98 | let acct = this.accounts.get(key); 99 | if (!acct || force) { 100 | const protocol = this.getProtocol(protocolId); 101 | if (protocol === undefined) { 102 | throw new Error("Protocol not found"); 103 | } 104 | acct = new PurpleAccount(username, protocol); 105 | this.accounts.set(key, acct); 106 | } 107 | return acct; 108 | } 109 | 110 | public getProtocol(id: string): BifrostProtocol|undefined { 111 | return this.protocols.find((proto) => proto.id === id); 112 | } 113 | 114 | public getProtocols(): BifrostProtocol[] { 115 | return this.protocols; 116 | } 117 | 118 | public findProtocol(nameOrId: string): BifrostProtocol|undefined { 119 | return this.getProtocols().find( 120 | (protocol) => protocol.name.toLowerCase() === nameOrId || protocol.id.toLowerCase() === nameOrId, 121 | ); 122 | } 123 | 124 | public getNickForChat(conv: Conversation): string { 125 | return messaging.getNickForChat(conv.handle); 126 | } 127 | 128 | public needsDedupe() { 129 | return true; 130 | } 131 | 132 | public needsAccountLock() { 133 | return true; 134 | } 135 | 136 | public async close() { 137 | if (this.interval) { 138 | clearInterval(this.interval); 139 | this.interval = undefined; 140 | } 141 | } 142 | 143 | public getUsernameFromMxid( 144 | mxid: string, 145 | prefix: string = ""): {username: string, protocol: BifrostProtocol} { 146 | const local = mxid.substring(`@${prefix}`.length).split(":")[0]; 147 | const [protocolId, ...usernameParts] = local.split("_"); 148 | if (this.backendOpts.soloProtocol) { 149 | // This is using a solo protocol, so ignore the leading protocol name. 150 | usernameParts.splice(0,0, protocolId); 151 | } 152 | // As per bifrost/Protocol.ts, we remove prpl- 153 | const protocol = this.getProtocol(this.backendOpts.soloProtocol || `prpl-${protocolId}`); 154 | const username = usernameParts.join("_").replace(/=3a/g, ":").replace(/=40/g, "@"); 155 | if (!protocol) { 156 | throw Error(`Could not find protocol ${protocol}`); 157 | } 158 | return { 159 | protocol, 160 | username, 161 | } 162 | } 163 | 164 | public eventAck() { 165 | // This is for handling stuff after an event has been sent. 166 | } 167 | 168 | private eventHandler() { 169 | helper.pollEvents().forEach((evt) => { 170 | if (!["received-chat-msg"].includes(evt.eventName)) { 171 | log.debug(`Got ${evt.eventName} from purple`); 172 | } 173 | if (evt.eventName === "chat-joined") { 174 | const chatJoined = evt as IConversationEvent; 175 | const purpleAccount = this.getAccount(chatJoined.account.username, chatJoined.account.protocol_id, ""); 176 | if (purpleAccount) { 177 | if (purpleAccount.waitingJoinRoomProps) { 178 | // tslint:disable-next-line 179 | const join_properties = purpleAccount.waitingJoinRoomProps; 180 | this.emit("chat-joined-new", Object.assign(evt, { 181 | purpleAccount, 182 | join_properties, 183 | should_invite: true, 184 | })); 185 | purpleAccount.eraseWaitingJoinRoomProps(); 186 | } 187 | } 188 | } 189 | if (["received-chat-msg", "received-im-msg"].includes(evt.eventName)) { 190 | const rawEvent = evt as Record; 191 | evt = Object.assign(evt, { 192 | message: { 193 | body: rawEvent.message, 194 | }, 195 | }); 196 | } 197 | this.emit(evt.eventName, evt); 198 | if (evt.eventName === "user-info-response") { 199 | const uinfo = evt as IUserInfo; 200 | const pAccount = this.accounts.get(`${uinfo.account.protocol_id}://${uinfo.account.username}`); 201 | if (pAccount) { 202 | pAccount.passUserInfoResponse(uinfo); 203 | } else { 204 | log.warn("No account found for response"); 205 | } 206 | } 207 | }); 208 | } 209 | 210 | } 211 | -------------------------------------------------------------------------------- /src/purple/PurpleProtocol.ts: -------------------------------------------------------------------------------- 1 | import { MatrixUser } from "matrix-appservice-bridge"; 2 | import { BifrostProtocol } from "../bifrost/Protocol"; 3 | 4 | export class PurpleProtocol extends BifrostProtocol { 5 | constructor(opts: {id: string, name: string, homepage?: string, summary?: string}, private isSoloProtocol = false) { 6 | super(opts, true, true); 7 | } 8 | 9 | public getMxIdForProtocol( 10 | senderId: string, 11 | domain: string, 12 | prefix: string = "") { 13 | // senderId containing : can mess things up 14 | senderId = senderId.replace(/\:/g, "=3a").replace(/=40/g, "@"); 15 | if (this.isSoloProtocol) { 16 | new MatrixUser(`@${prefix}${senderId}:${domain}`); 17 | } 18 | // This is a little bad, but we drop the prpl- because it's a bit ugly. 19 | const protocolName = this.id.startsWith("prpl-") ? this.id.substr("prpl-".length) : this.id; 20 | return new MatrixUser(`@${prefix}${protocolName}_${senderId}:${domain}`); 21 | } 22 | } -------------------------------------------------------------------------------- /src/store/BifrostRemoteUser.ts: -------------------------------------------------------------------------------- 1 | import { RemoteUser, AppServiceBot } from "matrix-appservice-bridge"; 2 | 3 | export class BifrostRemoteUser { 4 | public static fromRemoteUser(remoteUser: RemoteUser, asBot: AppServiceBot, userId: string): BifrostRemoteUser { 5 | return new BifrostRemoteUser( 6 | remoteUser.getId(), 7 | remoteUser.get("username"), 8 | remoteUser.get("protocol_id") || remoteUser.get("protocolId"), 9 | asBot.isRemoteUser(userId), 10 | remoteUser.get("displayname"), 11 | remoteUser.data, 12 | ); 13 | } 14 | 15 | constructor( 16 | public readonly id: string, 17 | public readonly username: string, 18 | public readonly protocolId: string, 19 | public readonly isRemote: boolean, 20 | public readonly displayname?: string, 21 | public readonly extraData: unknown = {}) { 22 | // do nothing 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/store/Store.ts: -------------------------------------------------------------------------------- 1 | import { MatrixUser, Bridge, RoomBridgeStoreEntry } from "matrix-appservice-bridge"; 2 | import { IRemoteRoomData, IRemoteGroupData, MROOM_TYPES } from "./Types"; 3 | import { BifrostProtocol } from "../bifrost/Protocol"; 4 | import { IAccountMinimal } from "../bifrost/Events"; 5 | import { BifrostRemoteUser } from "./BifrostRemoteUser"; 6 | import { IConfigDatastore } from "../Config"; 7 | import { NeDBStore } from "./NeDBStore"; 8 | import { PgDataStore } from "./postgres/PgDatastore"; 9 | 10 | export async function initiateStore(config: IConfigDatastore, bridge: Bridge): Promise { 11 | if (config.engine === "nedb") { 12 | return new NeDBStore(bridge); 13 | } else if (config.engine === "postgres") { 14 | const pg = new PgDataStore(config); 15 | await pg.ensureSchema(); 16 | return pg; 17 | } 18 | throw Error("Database engine not supported"); 19 | } 20 | 21 | export interface IStore { 22 | 23 | getMatrixUser(id: string): Promise; 24 | 25 | getMatrixUserForAccount(account: IAccountMinimal): Promise; 26 | 27 | setMatrixUser(matrix: MatrixUser): Promise; 28 | 29 | getRemoteUserBySender(sender: string, protocol: BifrostProtocol): Promise; 30 | 31 | getAccountsForMatrixUser(userId: string, protocolId: string): Promise; 32 | 33 | getAllAccountsForMatrixUser(userId: string): Promise; 34 | 35 | getRemoteUsersFromMxId(userId: string): Promise; 36 | 37 | getGroupRoomByRemoteData(remoteData: IRemoteRoomData|IRemoteGroupData): Promise; 38 | 39 | getAdminRoom(matrixUserId: string): Promise; 40 | 41 | getIMRoom(matrixUserId: string, protocolId: string, remoteUserId: string): Promise; 42 | 43 | getUsernameMxidForProtocol(protocol: BifrostProtocol): Promise<{[mxid: string]: string}>; 44 | 45 | getRoomsOfType(type: MROOM_TYPES): Promise; 46 | 47 | storeGhost(userId: string, protocol: BifrostProtocol, username: string, extraData?: any) 48 | : Promise<{remote: BifrostRemoteUser, matrix: MatrixUser}>; 49 | storeAccount(userId: string, protocol: BifrostProtocol, username: string, extraData?: any): Promise; 50 | removeRoomByRoomId(matrixId: string): Promise; 51 | 52 | getRoomEntryByMatrixId(roomId: string): Promise; 53 | 54 | storeRoom(matrixId: string, type: MROOM_TYPES, remoteId: string, remoteData: IRemoteRoomData) 55 | : Promise; 56 | 57 | getMatrixEventId(roomId: string, remoteEventId: string): Promise; 58 | 59 | getRemoteEventId(roomId: string, matrixEventId: string): Promise; 60 | 61 | storeRoomEvent(roomId: string, matrixEventId: string, remoteEventId: string): Promise; 62 | 63 | integrityCheck(canWrite: boolean): Promise; 64 | 65 | getAllIMRoomsForAccount(userId: string, protocolId: string): Promise; 66 | } 67 | -------------------------------------------------------------------------------- /src/store/Types.ts: -------------------------------------------------------------------------------- 1 | import { MatrixRoom, RemoteRoom } from "matrix-appservice-bridge"; 2 | import { IChatJoinProperties } from "../bifrost/Events"; 3 | 4 | export const MROOM_TYPE_UADMIN = "user-admin"; 5 | export const MROOM_TYPE_IM = "im"; 6 | export const MROOM_TYPE_GROUP = "group"; 7 | 8 | export const MUSER_TYPE_ACCOUNT = "account"; 9 | export const MUSER_TYPE_GHOST = "ghost"; 10 | 11 | export type MROOM_TYPES = "user-admin"|"im"|"group"; 12 | export type MUSER_TYPES = "account"|"ghost"; 13 | 14 | export interface IRemoteRoomData { 15 | protocol_id?: string; 16 | } 17 | 18 | export interface IRemoteGroupData extends IRemoteRoomData { 19 | room_name?: string; 20 | properties?: IChatJoinProperties; 21 | gateway?: boolean; 22 | plumbed?: boolean; 23 | } 24 | 25 | export interface IRemoteImData extends IRemoteRoomData { 26 | matrixUser?: string; 27 | recipient?: string; 28 | } 29 | 30 | export interface IRemoteUserAdminData extends IRemoteRoomData { 31 | matrixUser?: string; 32 | } 33 | 34 | export interface IMatrixUserData { 35 | accounts: {[key: string]: IRemoteUserAccount}; 36 | } 37 | 38 | export interface IRemoteUserAccount { 39 | // XXX: We are mixing camel case and snake case in here. 40 | type: MUSER_TYPES; 41 | username: string; 42 | protocolId: string; 43 | /** 44 | * @deprecated Use type: "ghost" 45 | */ 46 | isRemoteUser: boolean; 47 | } 48 | 49 | export interface IRemoteUserAccountRemote extends IRemoteUserAccount { 50 | isRemoteUser: true; 51 | /** 52 | * Last time the profile was checked for this remote user, in milliseconds 53 | */ 54 | last_check?: number; 55 | displayname?: string; 56 | avatar_url?: string; 57 | protocol_data: {[key: string]: string|number}; 58 | } -------------------------------------------------------------------------------- /src/store/postgres/schema/v1.ts: -------------------------------------------------------------------------------- 1 | import { PoolClient } from "pg"; 2 | 3 | export async function runSchema(connection: PoolClient) { 4 | // Create schema 5 | await connection.query(` 6 | CREATE TABLE schema ( 7 | version INTEGER UNIQUE NOT NULL 8 | ); 9 | 10 | INSERT INTO schema VALUES (0); 11 | 12 | CREATE TABLE rooms ( 13 | room_id TEXT 14 | ); 15 | 16 | CREATE TABLE group_rooms ( 17 | room_id TEXT UNIQUE NOT NULL, 18 | protocol_id TEXT NULL, 19 | room_name TEXT, 20 | gateway BOOLEAN, 21 | properties JSONB 22 | ) INHERITS (rooms); 23 | 24 | CREATE TABLE im_rooms ( 25 | room_id TEXT UNIQUE NOT NULL, 26 | user_id TEXT NOT NULL, 27 | remote_id TEXT NOT NULL, 28 | protocol_id TEXT NOT NULL 29 | ) INHERITS (rooms); 30 | 31 | 32 | CREATE TABLE admin_rooms ( 33 | room_id TEXT UNIQUE NOT NULL, 34 | user_id TEXT 35 | ) INHERITS (rooms); 36 | 37 | CREATE TABLE accounts ( 38 | user_id TEXT NOT NULL, 39 | protocol_id TEXT NOT NULL, 40 | username TEXT NOT NULL, 41 | extra_data JSONB, 42 | CONSTRAINT cons_accounts_unique UNIQUE(user_id, protocol_id, username), 43 | CONSTRAINT cons_accounts_protousername_unique UNIQUE(protocol_id, username) 44 | ); 45 | 46 | CREATE TABLE ghost_cache ( 47 | user_id TEXT UNIQUE NOT NULL, 48 | displayname TEXT, 49 | avatar_url TEXT 50 | ); 51 | 52 | CREATE TABLE remote_users ( 53 | user_id TEXT UNIQUE NOT NULL, 54 | sender_name TEXT, 55 | protocol_id TEXT, 56 | extra_data JSONB 57 | ); 58 | `); 59 | } 60 | -------------------------------------------------------------------------------- /src/store/postgres/schema/v2.ts: -------------------------------------------------------------------------------- 1 | import { PoolClient } from "pg"; 2 | 3 | export async function runSchema(connection: PoolClient) { 4 | // Create schema 5 | await connection.query(`CREATE TABLE events ( 6 | room_id TEXT NOT NULL, 7 | matrix_id TEXT NOT NULL, 8 | remote_id TEXT NOT NULL, 9 | CONSTRAINT cons_mxev_unique UNIQUE(matrix_id, room_id), 10 | CONSTRAINT cons_rmev_unique UNIQUE(remote_id, room_id) 11 | )`); 12 | } 13 | -------------------------------------------------------------------------------- /src/xmppjs/GatewayMUCMembership.ts: -------------------------------------------------------------------------------- 1 | import { JID, jid } from "@xmpp/jid"; 2 | 3 | interface IGatewayMember { 4 | type: "xmpp"|"matrix"; 5 | anonymousJid: JID; 6 | matrixId: string; 7 | } 8 | 9 | export interface IGatewayMemberXmpp extends IGatewayMember { 10 | type: "xmpp"; 11 | realJid: JID; 12 | devices: Set; 13 | } 14 | 15 | export interface IGatewayMemberMatrix extends IGatewayMember { 16 | type: "matrix"; 17 | } 18 | 19 | const FLAT_SUPPORTED = [].flat !== undefined; 20 | 21 | class ChatMembers { 22 | constructor( 23 | private members = new Set(), 24 | private byMxid = new Map(), 25 | ) {} 26 | 27 | add(member: IGatewayMember): void { 28 | this.members.add(member); 29 | this.byMxid.set(member.matrixId, member); 30 | } 31 | 32 | delete(member: IGatewayMember): boolean { 33 | this.byMxid.delete(member.matrixId); 34 | return this.members.delete(member); 35 | } 36 | 37 | getAll(): Iterable { 38 | return this.members.values(); 39 | } 40 | 41 | getByMxid(mxid: string): IGatewayMember|undefined { 42 | return this.byMxid.get(mxid); 43 | } 44 | } 45 | 46 | /** 47 | * Handles storage of MUC membership for matrix and xmpp users. 48 | */ 49 | export class GatewayMUCMembership { 50 | private members: Map; // chatName -> members 51 | 52 | constructor() { 53 | this.members = new Map(); 54 | } 55 | 56 | public hasMembershipForRoom(chatName: string) { 57 | return this.members.has(chatName); 58 | } 59 | 60 | public getAnonJidsForXmppJid(realJid: string|JID) { 61 | // Strip the resource. 62 | const jids: {[chatName: string]: {devices: string[], jid: string}} = {}; 63 | for (const chatName of this.members.keys()) { 64 | const member = this.getXmppMemberByRealJid(chatName, realJid); 65 | if (member) { 66 | jids[chatName] = { 67 | devices: [...member.devices], 68 | jid: member.anonymousJid.toString(), 69 | } 70 | } 71 | } 72 | return jids; 73 | } 74 | 75 | public getMemberByAnonJid(chatName: string, anonJid: string): G|undefined { 76 | return Array.from(this.getMembers(chatName)).find((user) => user.anonymousJid.toString() === anonJid) as G; 77 | } 78 | 79 | public getMatrixMemberByMatrixId(chatName: string, matrixId: string): IGatewayMemberMatrix|undefined { 80 | const member = this.members.get(chatName)?.getByMxid(matrixId); 81 | if (member && member.type === 'matrix') { 82 | return member as IGatewayMemberMatrix; 83 | } 84 | } 85 | 86 | public getXmppMemberByDevice(chatName: string, realJid: string|JID): IGatewayMemberXmpp|undefined { 87 | const j = typeof(realJid) !== "string" ? realJid.toString() : realJid; 88 | const member = this.getXmppMembers(chatName).find((user) => user.devices.has(j)); 89 | return member; 90 | } 91 | 92 | public getXmppMemberByRealJid(chatName: string, realJid: string|JID): IGatewayMemberXmpp|undefined { 93 | // Strip the resource. 94 | const j = typeof(realJid) === "string" ? jid(realJid) : realJid; 95 | const strippedJid = `${j.local}@${j.domain}`; 96 | const member = this.getXmppMembers(chatName).find((user) => user.realJid.toString() === strippedJid); 97 | return member; 98 | } 99 | 100 | public getXmppMemberByMatrixId(chatName: string, matrixId: string): IGatewayMemberXmpp|undefined { 101 | const chatMembers = this.members.get(chatName); 102 | if (chatMembers) { 103 | const member = chatMembers.getByMxid(matrixId); 104 | if (member?.type === 'xmpp') { 105 | return member as IGatewayMemberXmpp; 106 | } 107 | } 108 | } 109 | 110 | public getXmppMembers(chatName: string): IGatewayMemberXmpp[] { 111 | return Array.from(this.getMembers(chatName)).filter((s) => s.type === "xmpp") as IGatewayMemberXmpp[]; 112 | } 113 | 114 | public getXmppMembersDevices(chatName: string): Set { 115 | if (FLAT_SUPPORTED) { 116 | return new Set(this.getXmppMembers(chatName).map((u) => [...u.devices]).flat()); 117 | } else { 118 | return new Set(this.getXmppMembers(chatName).map((u) => [...u.devices]).reduce((acc, val) => [ ...acc, ...val ], [])); 119 | } 120 | } 121 | 122 | public getMatrixMembers(chatName: string): IGatewayMemberMatrix[] { 123 | return Array.from(this.getMembers(chatName)).filter((s) => s.type === "matrix") as IGatewayMemberMatrix[]; 124 | } 125 | 126 | public getMembers(chatName: string): Iterable { 127 | const chatMembers = this.members.get(chatName); 128 | if (chatMembers) { 129 | return chatMembers.getAll(); 130 | } else { 131 | return []; 132 | } 133 | } 134 | 135 | public addMatrixMember(chatName: string, matrixId: string, anonymousJid: JID): boolean { 136 | if (this.getMatrixMemberByMatrixId(chatName, matrixId)) { 137 | return false; 138 | } 139 | 140 | const chatMembers = this.members.get(chatName) || new ChatMembers(); 141 | chatMembers.add({ 142 | type: "matrix", 143 | anonymousJid, 144 | matrixId, 145 | } as IGatewayMemberMatrix); 146 | this.members.set(chatName, chatMembers); 147 | return true; 148 | } 149 | 150 | /** 151 | * Add an XMPP member to a MUC chat. 152 | * 153 | * @param chatName The MUC name. 154 | * @param realJid The real JID for the XMPP user. 155 | * @param anonymousJid The anonymous JID for the the user in the context of the MUC. 156 | * @param matrixId The assigned Matrix UserID for the user. 157 | * @returns True if this is the first device for a user, false otherwise. 158 | */ 159 | public addXmppMember(chatName: string, realJid: JID, anonymousJid: JID, matrixId: string): boolean { 160 | const strippedDevice = jid(`${realJid.local}@${realJid.domain}`); 161 | const member = this.getXmppMemberByRealJid(chatName, strippedDevice.toString()); 162 | if (member) { 163 | member.devices.add(realJid.toString()); 164 | return false; 165 | } 166 | const chatMembers = this.members.get(chatName) || new ChatMembers(); 167 | chatMembers.add({ 168 | type: "xmpp", 169 | anonymousJid, 170 | realJid: strippedDevice, 171 | devices: new Set([realJid.toString()]), 172 | matrixId, 173 | } as IGatewayMemberXmpp); 174 | this.members.set(chatName, chatMembers); 175 | return true; 176 | } 177 | 178 | public removeMatrixMember(chatName: string, matrixId: string): boolean { 179 | const chatMembers = this.members.get(chatName); 180 | if (chatMembers) { 181 | const member = chatMembers.getByMxid(matrixId); 182 | if (member) { 183 | chatMembers.delete(member); 184 | return true; 185 | } 186 | } 187 | return false; 188 | } 189 | 190 | /** 191 | * Remove an XMPP member from the gateway membership. 192 | * 193 | * @param chatName The MUC the user is part of 194 | * @param realJid The real JID of the user 195 | * @returns True if this is the last device for this member, false otherwise. 196 | */ 197 | public removeXmppMember(chatName: string, realJid: string|JID): boolean { 198 | realJid = typeof(realJid) === "string" ? jid(realJid) : realJid; 199 | const member = this.getXmppMemberByRealJid(chatName, realJid); 200 | if (!member) { 201 | return false; 202 | } 203 | if (realJid.resource) { 204 | member.devices.delete(realJid.toString()); 205 | if (member.devices.size) { 206 | return false; 207 | } 208 | } 209 | const chatMembers = this.members.get(chatName); 210 | return chatMembers ? chatMembers.delete(member) : true; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/xmppjs/GatewayStateResolve.ts: -------------------------------------------------------------------------------- 1 | import jid from "@xmpp/jid"; 2 | import { Logger } from "matrix-appservice-bridge"; 3 | import { MatrixMembershipEvent } from "../MatrixTypes"; 4 | import { GatewayMUCMembership } from "./GatewayMUCMembership"; 5 | import { IStza, PresenceAffiliation, PresenceRole, StzaBase, StzaPresenceItem } from "./Stanzas"; 6 | import { XMPPStatusCode } from "./XMPPConstants"; 7 | 8 | const log = new Logger("GatewayStateResolve"); 9 | 10 | function sendToAllDevices(presence: StzaPresenceItem, devices: Set) { 11 | return [...devices].map((deviceJid) => 12 | new StzaPresenceItem( 13 | presence.from, 14 | deviceJid, 15 | undefined, 16 | presence.affiliation, 17 | presence.role, 18 | false, 19 | undefined, 20 | presence.presenceType, 21 | ) 22 | ) 23 | 24 | } 25 | 26 | export class GatewayStateResolve { 27 | static resolveMatrixStateToXMPP(chatName: string, members: GatewayMUCMembership, event: MatrixMembershipEvent): IStza[] { 28 | const membership = event.content.membership; 29 | let stanzas: IStza[] = []; 30 | const allDevices = members.getXmppMembersDevices(chatName); 31 | const from = `${chatName}/` + (event.content.displayname || event.state_key); 32 | if (allDevices.size === 0) { 33 | log.warn("No users found for gateway room!"); 34 | return stanzas; 35 | } 36 | const existingMember = members.getMatrixMemberByMatrixId(chatName, event.state_key); 37 | const xmppMember = members.getXmppMemberByMatrixId(chatName, event.state_key); 38 | if (membership === "join") { 39 | log.info(`Joining a Matrix user ${event.state_key}`); 40 | if (existingMember) { 41 | // Do not handle if we already have them 42 | return []; 43 | } 44 | if (xmppMember) { 45 | // Catch to avoid double bridging. 46 | return []; 47 | } 48 | // Matrix Join 49 | members.addMatrixMember(chatName, event.state_key, jid(from)); 50 | // Reflect to all 51 | stanzas = sendToAllDevices( 52 | new StzaPresenceItem( 53 | from, 54 | "", 55 | undefined, 56 | PresenceAffiliation.Member, 57 | PresenceRole.Participant 58 | ), allDevices, 59 | ); 60 | } else if (membership === "leave" && event.state_key === event.sender) { 61 | log.info(`Leaving a Matrix user ${event.state_key}`); 62 | if (!existingMember) { 63 | // Do not handle if we don't have them 64 | return []; 65 | } 66 | // Matrix leave 67 | members.removeMatrixMember(chatName, event.state_key); 68 | // Reflect to all 69 | stanzas = sendToAllDevices( 70 | new StzaPresenceItem( 71 | existingMember.anonymousJid.toString(), 72 | "", 73 | undefined, 74 | PresenceAffiliation.Member, 75 | PresenceRole.None, 76 | false, 77 | undefined, 78 | "unavailable", 79 | ), allDevices, 80 | ); 81 | } else if ((membership === "leave" || membership === "ban") && event.state_key !== event.sender) { 82 | const kicker = members.getMatrixMemberByMatrixId(chatName, event.sender); 83 | if (existingMember) { 84 | log.info(`Kicking a Matrix user ${event.state_key}`); 85 | // This is Matrix -> Matrix 86 | members.removeMatrixMember(chatName, event.state_key); 87 | // Reflect to all 88 | const presence = new StzaPresenceItem( 89 | existingMember.anonymousJid.toString(), 90 | "", 91 | undefined, 92 | PresenceAffiliation.None, 93 | PresenceRole.None, 94 | false, 95 | undefined, 96 | "unavailable", 97 | ); 98 | presence.actor = kicker?.anonymousJid.getResource(); 99 | presence.reason = event.content.reason; 100 | presence.statusCodes.add(XMPPStatusCode.SelfKicked); 101 | stanzas = sendToAllDevices(presence, allDevices); 102 | } else if (xmppMember) { 103 | log.info(`Kicking a XMPP user ${event.state_key}`); 104 | // This is Matrix -> XMPP 105 | members.removeXmppMember(chatName, xmppMember.realJid.toString()); 106 | 107 | const presenceSelf = new StzaPresenceItem( 108 | xmppMember.anonymousJid.toString(), 109 | "", 110 | undefined, 111 | membership === "leave" ? PresenceAffiliation.None : PresenceAffiliation.Outcast, 112 | PresenceRole.None, 113 | true, 114 | undefined, 115 | "unavailable", 116 | ); 117 | presenceSelf.actor = kicker?.anonymousJid.getResource(); 118 | presenceSelf.reason = event.content.reason; 119 | presenceSelf.statusCodes.add( membership === "leave" ? XMPPStatusCode.SelfKicked : XMPPStatusCode.SelfBanned); 120 | 121 | // Tell the XMPP user's devices. 122 | stanzas.push(...sendToAllDevices(presenceSelf, xmppMember.devices)); 123 | 124 | const presence = new StzaPresenceItem( 125 | xmppMember.anonymousJid.toString(), 126 | "", 127 | undefined, 128 | membership === "leave" ? PresenceAffiliation.None : PresenceAffiliation.Outcast, 129 | PresenceRole.None, 130 | false, 131 | undefined, 132 | "unavailable", 133 | ); 134 | presence.statusCodes.add( membership === "leave" ? XMPPStatusCode.SelfKicked : XMPPStatusCode.SelfBanned); 135 | // Tell the others 136 | stanzas = sendToAllDevices(presence, allDevices); 137 | } else { 138 | // We're not sure what this is, nope out to play it safe. 139 | return []; 140 | } 141 | } else if (membership === "invite") { 142 | // TODO: Invites 143 | } 144 | return stanzas; 145 | } 146 | } -------------------------------------------------------------------------------- /src/xmppjs/PresenceCache.ts: -------------------------------------------------------------------------------- 1 | import xml from "@xmpp/xml"; 2 | import jid from "@xmpp/jid"; 3 | import { XMPPStatusCode } from "./XMPPConstants"; 4 | 5 | export interface IPresenceDelta { 6 | status?: IPresenceStatus; 7 | changed: string[]; 8 | isSelf: boolean; 9 | error: "conflict"|"other"|null; 10 | errorMsg: string|null; 11 | } 12 | 13 | export interface IPresenceStatus { 14 | resource: string; 15 | online: boolean; 16 | kick: { 17 | reason: string | null; 18 | kicker: string | null; 19 | } | undefined; 20 | status: string; 21 | affiliation: string; 22 | role: string; 23 | ours: boolean; 24 | // For gateways only 25 | devices: Set|undefined; 26 | photoId: string|undefined; 27 | } 28 | 29 | /** 30 | * This class holds presence for XMPP users across MUCs and 31 | * determines deltas. 32 | */ 33 | export class PresenceCache { 34 | private presence: Map; 35 | 36 | constructor(private isGateway: boolean = false) { 37 | this.presence = new Map(); 38 | } 39 | 40 | public clear() { 41 | this.presence.clear(); 42 | } 43 | 44 | public getStatus(userJid: string): IPresenceStatus|undefined { 45 | const exactMatch = this.presence.get(userJid); 46 | if (exactMatch) { 47 | return exactMatch; 48 | } 49 | for (const k of this.presence.keys()) { 50 | if (k.startsWith(userJid)) { 51 | return this.presence.get(k); 52 | } 53 | } 54 | } 55 | 56 | public modifyStatus(userJid: string, status: IPresenceStatus) { 57 | this.presence.set(userJid, status); 58 | } 59 | 60 | public add(stanza: xml.Element): IPresenceDelta|undefined { 61 | const errorElement = stanza.getChild("error"); 62 | if (errorElement) { 63 | const conflict = errorElement.getChild("conflict"); 64 | if (conflict) { 65 | return { 66 | changed: [], 67 | error: "conflict", 68 | isSelf: true, 69 | errorMsg: null, 70 | }; 71 | } 72 | return { 73 | changed: [], 74 | error: "other", 75 | isSelf: false, 76 | errorMsg: errorElement.toString(), 77 | }; 78 | } 79 | const type = stanza.getAttr("type")!; 80 | // If it's a gateway, we want to invert this. 81 | const from = jid.parse( this.isGateway ? stanza.attrs.to : stanza.attrs.from); 82 | let device: string|null = null; 83 | if (this.isGateway) { 84 | device = jid.parse(stanza.attrs.from).resource; 85 | } 86 | 87 | if (!from.resource) { 88 | // Not sure how to handle presence without a resource. 89 | return; 90 | } 91 | const mucUser = stanza.getChild("x", "http://jabber.org/protocol/muc#user"); 92 | const isSelf = !!(mucUser && mucUser.getChildByAttr("code", XMPPStatusCode.SelfPresence)); 93 | const isKick = !!(mucUser && mucUser.getChildByAttr("code", XMPPStatusCode.SelfKicked)); 94 | const isTechnicalRemoval = !!(mucUser && mucUser.getChildByAttr("code", XMPPStatusCode.SelfKickedTechnical)); 95 | const newUser = !this.presence.get(from.toString()); 96 | const currentPresence = this.presence.get(from.toString()) || { 97 | resource: from.resource, 98 | status: "", 99 | affiliation: "", 100 | role: "", 101 | online: false, 102 | ours: false, 103 | devices: this.isGateway ? new Set() : undefined, 104 | } as IPresenceStatus; 105 | const delta: IPresenceDelta = {changed: [], isSelf, error: null, errorMsg: null}; 106 | 107 | if (device && !currentPresence.devices!.has(device)) { 108 | currentPresence.devices!.add(device); 109 | delta.changed.push("newdevice"); 110 | } 111 | 112 | if (newUser) { 113 | delta.changed.push("new"); 114 | } 115 | 116 | if (isSelf) { 117 | currentPresence.ours = true; 118 | this.presence.set(from.toString(), currentPresence); 119 | } 120 | 121 | if (mucUser && mucUser.getChild("item")) { 122 | const affiliation = mucUser.getChild("item")!.getAttr("affiliation"); 123 | const role = mucUser.getChild("item")!.getAttr("role"); 124 | 125 | if (affiliation !== currentPresence.affiliation) { 126 | delta.changed.push("affiliation"); 127 | } 128 | 129 | if (role !== currentPresence.role) { 130 | delta.changed.push("role"); 131 | } 132 | 133 | currentPresence.affiliation = affiliation; 134 | currentPresence.role = role; 135 | } 136 | if (type === "unavailable") { 137 | if (currentPresence.online) { 138 | currentPresence.online = false; 139 | currentPresence.status = stanza.getChildText("status") || ""; 140 | delta.status = currentPresence; 141 | delta.changed.push("offline"); 142 | } 143 | } else if (!currentPresence.online || isSelf) { 144 | delta.changed.push("online"); 145 | currentPresence.online = true; 146 | delta.status = currentPresence; 147 | } 148 | 149 | if (isKick) { 150 | delta.changed.push("kick"); 151 | const item = mucUser!.getChild("item"); 152 | if (item) { 153 | const actor = item.getChild("actor"); 154 | currentPresence.kick = { 155 | kicker: actor ? actor.getAttr("nick") : null, 156 | reason: item.getChildText("reason"), 157 | }; 158 | } 159 | } 160 | 161 | const vcard = stanza.getChildByAttr("xmlns", "vcard-temp:x:update"); 162 | if (vcard) { 163 | const photoId = vcard.getChildText("photo") || undefined; 164 | if (photoId !== currentPresence.photoId) { 165 | currentPresence.photoId = photoId; 166 | delta.status = currentPresence; 167 | delta.changed.push("photo"); 168 | } 169 | } 170 | 171 | if (delta.status) { 172 | this.presence.set(from.toString(), currentPresence); 173 | } 174 | return delta; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/xmppjs/XHTMLIM.ts: -------------------------------------------------------------------------------- 1 | import { Parser } from "htmlparser2"; 2 | import * as he from "html-entities"; 3 | 4 | const XMLNS = "http://jabber.org/protocol/xhtml-im"; 5 | 6 | const VALID_ELEMENT_ATTRIBUTES = { 7 | // Defined Structure Module Elements and Attributes 8 | body: ["class", "id", "title", "style"], 9 | head: ["profile"], 10 | html: ["version"], 11 | title: [], 12 | // Defined Text Module Elements and Attributes 13 | abbr: ["class", "id", "title", "style"], 14 | acronym: ["class", "id", "title", "style"], 15 | address: ["class", "id", "title", "style"], 16 | blockquote: ["class", "id", "title", "style", "cite"], 17 | br: ["class", "id", "title", "style"], 18 | cite: ["class", "id", "title", "style"], 19 | code: ["class", "id", "title", "style"], 20 | dfn: ["class", "id", "title", "style"], 21 | div: ["class", "id", "title", "style"], 22 | em: ["class", "id", "title", "style"], 23 | h1: ["class", "id", "title", "style"], 24 | h2: ["class", "id", "title", "style"], 25 | h3: ["class", "id", "title", "style"], 26 | h4: ["class", "id", "title", "style"], 27 | h5: ["class", "id", "title", "style"], 28 | h6: ["class", "id", "title", "style"], 29 | kbd: ["class", "id", "title", "style"], 30 | p: ["class", "id", "title", "style"], 31 | pre: ["class", "id", "title", "style"], 32 | q: ["class", "id", "title", "style", "cite"], 33 | samp: ["class", "id", "title", "style"], 34 | span: ["class", "id", "title", "style"], 35 | strong: ["class", "id", "title", "style"], 36 | var: ["class", "id", "title", "style"], 37 | // Hypertext Module Definition 38 | a: ["class", "id", "title", "style", "accesskey", "charset", "href", "hreflang", "rel", "rev", "tabindex", "type"], 39 | // List Module Definition 40 | dl: ["class", "id", "title", "style"], 41 | dt: ["class", "id", "title", "style"], 42 | dd: ["class", "id", "title", "style"], 43 | ol: ["class", "id", "title", "style"], 44 | ul: ["class", "id", "title", "style"], 45 | li: ["class", "id", "title", "style"], 46 | // Image Module Definition 47 | img: ["class", "id", "title", "style", "alt", "height", "longdesc", "src", "width"], 48 | }; 49 | 50 | export class XHTMLIM { 51 | public static HTMLToXHTML(html: string) { 52 | let xhtml = ""; 53 | const parser = new Parser({ 54 | onopentag: (tagname, rawAttribs) => { 55 | // Filter out any elements or attributes we cannot support. 56 | if (VALID_ELEMENT_ATTRIBUTES[tagname] === undefined) { 57 | return; 58 | } 59 | const attribs: {[key: string]: string } = {}; 60 | Object.keys(rawAttribs).filter( 61 | (a) => VALID_ELEMENT_ATTRIBUTES[tagname].includes(a.toLowerCase()), 62 | ).forEach((a) => { 63 | attribs[a] = he.encode(rawAttribs[a]); 64 | }); 65 | if (tagname === "html") { 66 | attribs.xmlns = XMLNS; 67 | } 68 | xhtml += `<${tagname}${Object.keys(attribs).map((k) => ` ${k}='${attribs[k]}'`).join("")}>`; 69 | }, 70 | ontext: (text) => { 71 | xhtml += `${he.encode(text)}`; 72 | }, 73 | onclosetag: (name) => { 74 | if (VALID_ELEMENT_ATTRIBUTES[name] === undefined) { 75 | return; 76 | } 77 | xhtml += ``; 78 | }, 79 | }, { 80 | decodeEntities: true, 81 | xmlMode: true, 82 | lowerCaseTags: true, 83 | lowerCaseAttributeNames: true, 84 | }); 85 | if (!html.startsWith("${html}`; 87 | } 88 | if (!html.toLowerCase().endsWith("")) { 89 | html += ""; 90 | } 91 | parser.write(html); 92 | parser.end(); 93 | return xhtml; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/xmppjs/XJSBackendOpts.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface JingleConfig { 3 | autodownload: boolean; 4 | } 5 | export interface IXJSBackendOpts { 6 | service: string; 7 | domain: string; 8 | password: string; 9 | defaultResource: string; 10 | logRawStream: boolean; 11 | jingle?: JingleConfig; 12 | } 13 | -------------------------------------------------------------------------------- /src/xmppjs/XJSConnection.ts: -------------------------------------------------------------------------------- 1 | import {Component} from "@xmpp/component-core"; 2 | import Reconnect from "@xmpp/reconnect"; 3 | 4 | export interface IXJSConnectionOptions { 5 | password: string; 6 | service: string; 7 | domain: string; 8 | } 9 | 10 | export class XJSConnection { 11 | public static connect(options: IXJSConnectionOptions) { 12 | const {password, service, domain} = options; 13 | 14 | const entity = new Component({service, domain}); 15 | 16 | const reconnect = Reconnect({entity}); 17 | 18 | entity.on("open", async (el) => { 19 | try { 20 | const {id} = el.attrs; 21 | await entity.authenticate(id, password); 22 | } catch (err) { 23 | entity.emit("error", err); 24 | } 25 | }); 26 | 27 | return Object.assign(entity, { 28 | entity, 29 | reconnect, 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/xmppjs/XMPPConstants.ts: -------------------------------------------------------------------------------- 1 | export enum XMPPStatusCode { 2 | RoomNonAnonymous = "100", 3 | SelfPresence = "110", 4 | RoomLoggingEnabled = "170", 5 | RoomLoggingDisabled = "171", 6 | RoomNowNonAnonymous = "172", 7 | SelfBanned = "301", 8 | SelfKicked = "307", 9 | SelfKickedTechnical = "333", 10 | } 11 | 12 | export enum XMPPFeatures { 13 | DiscoInfo = "http://jabber.org/protocol/disco#info", 14 | DiscoItems = "http://jabber.org/protocol/disco#items", 15 | Muc = "http://jabber.org/protocol/muc", 16 | IqVersion = "jabber:iq:version", 17 | IqSearch = "jabber:iq:search", 18 | MessageCorrection = "urn:xmpp:message-correct:0", 19 | XHTMLIM = "http://jabber.org/protocol/xhtml-im", 20 | Jingle = "urn:xmpp:jingle:1", 21 | // Swift uses v4 22 | JingleFileTransferV4 = "urn:xmpp:jingle:apps:file-transfer:4", 23 | // https://xmpp.org/extensions/xep-0234.html#appendix-revs 24 | // Everyone else uses V5 25 | // V5 changes are: 26 | // Update dependency on XEP-0300 to require the 'urn:xmpp:hashes:2' namespace that mandates base64 encoding. 27 | // Clarify that a element with a limit or offset value in a 'session-accept' should be honored by the file sender. 28 | JingleFileTransferV5 = "urn:xmpp:jingle:apps:file-transfer:5", 29 | JingleIBB = "urn:xmpp:jingle:transports:ibb:1", 30 | Receipts = "urn:xmpp:receipts", 31 | ChatStates = "http://jabber.org/protocol/chatstates", 32 | } -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | npm run build 3 | LD_PRELOAD=./node_modules/node-purple/deps/libpurple/libpurple.so npm start -- --port 3642 4 | #LD_PRELOAD=/usr/lib/libpurple.so.0 npm start -- --port 9555 5 | -------------------------------------------------------------------------------- /test/mocks/XJSInstance.ts: -------------------------------------------------------------------------------- 1 | import { IStza, StzaIqPing, StzaPresenceItem, StzaPresenceJoin } from "../../src/xmppjs/Stanzas"; 2 | import { EventEmitter } from "events"; 3 | import { Element, x } from "@xmpp/xml"; 4 | import { IChatJoined } from "../../src/bifrost/Events"; 5 | import { jid } from "@xmpp/jid"; 6 | import { XMPP_PROTOCOL } from "../../src/xmppjs/XJSInstance"; 7 | 8 | export class MockXJSInstance extends EventEmitter { 9 | public sentMessageIDs: string[] = []; 10 | public sentMessages: IStza[] = []; 11 | public sentPackets: Element[] = []; 12 | public selfPingResponse: "respond-ok"|"respond-error"|"no-response" = "respond-error"; 13 | public accountUsername!: string; 14 | 15 | public xmppAddSentMessage(id: string) { 16 | this.sentMessageIDs.push(id); 17 | } 18 | 19 | public xmppSend(msg: IStza) { 20 | if (msg instanceof StzaPresenceItem) { 21 | // Hack for tests, clone this. 22 | const item = new StzaPresenceItem(msg.from, msg.to, msg.id, msg.affiliation, msg.role, msg.self, msg.jid, msg.presenceType); 23 | msg.statusCodes.forEach(i => item.statusCodes.add(i)); 24 | this.sentMessages.push(item); 25 | } else { 26 | this.sentMessages.push(msg); 27 | } 28 | if (msg instanceof StzaIqPing) { 29 | if (this.selfPingResponse === "respond-ok") { 30 | this.emit("iq." + msg.id, x("iq")); 31 | } else if (this.selfPingResponse === "respond-error") { 32 | this.emit("iq." + msg.id, x("iq", {}, x("error"))); 33 | } 34 | } 35 | if (msg instanceof StzaPresenceJoin) { 36 | const to = jid(msg.to); 37 | const convName = `${to.local}@${to.domain}`; 38 | this.emit("chat-joined", { 39 | eventName: "chat-joined", 40 | conv: { 41 | name: convName, 42 | }, 43 | account: { 44 | protocol_id: XMPP_PROTOCOL.id, 45 | username: this.accountUsername, 46 | }, 47 | } as IChatJoined); 48 | } 49 | } 50 | 51 | public xmppWriteToStream(msg: Element) { 52 | this.sentPackets.push(msg); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/mocks/dummyprotocol.ts: -------------------------------------------------------------------------------- 1 | import { BifrostProtocol } from "../../src/bifrost/Protocol"; 2 | import { MatrixUser } from "matrix-appservice-bridge"; 3 | 4 | class DummyProtocol extends BifrostProtocol { 5 | constructor() { 6 | super({ 7 | id: "dummy", 8 | name: "Dummy", 9 | homepage: "N/A", 10 | summary: "Fake protocol for testing only", 11 | }, false, false); 12 | } 13 | 14 | public getMxIdForProtocol( 15 | senderId: string, 16 | domain: string, 17 | prefix: string = "") { 18 | return new MatrixUser(`@${prefix}${senderId}:${domain}`); 19 | } 20 | } 21 | 22 | export const dummyProtocol = new DummyProtocol(); 23 | -------------------------------------------------------------------------------- /test/mocks/intent.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export class MockIntent { 3 | public ensureRegisteredCalled: boolean = false; 4 | public leftRoom: string = ""; 5 | public clientJoinRoomCalledWith: {roomString: string, opts: any}|null = null; 6 | constructor(public userId: string) { 7 | 8 | } 9 | 10 | public async ensureRegistered() { 11 | this.ensureRegisteredCalled = true; 12 | } 13 | 14 | public async leave(roomString: string) { 15 | this.leftRoom = roomString; 16 | } 17 | 18 | public async roomState(roomId: string) { 19 | return []; 20 | } 21 | 22 | public async join(roomString: string, opts: any) { 23 | this.clientJoinRoomCalledWith = {roomString, opts}; 24 | roomString = roomString.startsWith("#") ? roomString.replace("#", "!") : roomString; 25 | return roomString; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/mocks/store.ts: -------------------------------------------------------------------------------- 1 | import Datastore from "nedb"; 2 | import { RoomBridgeStore, UserBridgeStore } from "matrix-appservice-bridge"; 3 | import { NeDBStore } from "../../src/store/NeDBStore"; 4 | import { IStore } from "../../src/store/Store"; 5 | 6 | const DEF_REGEX = /@remote/; 7 | 8 | export function mockStore(remoteUserRegex = DEF_REGEX): IStore { 9 | const userStore = new UserBridgeStore(new Datastore()); 10 | const roomStore = new RoomBridgeStore(new Datastore()); 11 | return new NeDBStore({ 12 | getRoomStore: () => roomStore, 13 | getUserStore: () => userStore, 14 | getBot: () => { 15 | const bot = { isRemoteUser: (u) => remoteUserRegex.exec(u) !== null } 16 | return bot as any; 17 | }, 18 | } as any); 19 | } 20 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "matrix-appservice-bridge"; 2 | 3 | if (process.argv.includes("--logging")) { 4 | Logger.configure({console: "debug"}); 5 | } else { 6 | Logger.configure({console: "error"}); 7 | } -------------------------------------------------------------------------------- /test/test_GatewayHandler.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as Chai from "chai"; 3 | import { IGatewayRoom } from "../src/bifrost/Gateway"; 4 | import { Config } from "../src/Config"; 5 | import { mockStore } from "./mocks/store"; 6 | import { EventEmitter } from "events"; 7 | import { IGatewayJoin } from "../src/bifrost/Events"; 8 | import { dummyProtocol } from "./mocks/dummyprotocol"; 9 | import { MockIntent } from "./mocks/intent"; 10 | import { MROOM_TYPE_GROUP, IRemoteGroupData } from "../src/store/Types"; 11 | import { GatewayHandler } from "../src/GatewayHandler"; 12 | const expect = Chai.expect; 13 | 14 | function createGH() { 15 | let remoteJoinResolve: any = null; 16 | const watch: any = { 17 | intent: null, 18 | profileUpdated: false, 19 | remoteJoin: null, 20 | remoteJoinPromise: new Promise((resolve) => remoteJoinResolve = resolve), 21 | }; 22 | const bridge = { 23 | getIntent: (userId) => { 24 | watch.intent = new MockIntent(userId); 25 | return watch.intent; 26 | }, 27 | getBot: () => ({ 28 | isRemoteUser: (userId: string) => userId.startsWith("@_prefix_"), 29 | }), 30 | }; 31 | let purple: any = { 32 | gateway: { 33 | onRemoteJoin: ( 34 | err: string|null, joinId: string, room: IGatewayRoom|undefined, ownMxid: string|undefined) => { 35 | watch.remoteJoin = {err, joinId, room, ownMxid}; 36 | remoteJoinResolve(); 37 | }, 38 | getMxidForRemote: (sender: string) => `@_prefix_${sender}:localhost`, 39 | }, 40 | getProtocol: () => dummyProtocol, 41 | }; 42 | purple = Object.assign(new EventEmitter(), purple); 43 | const profileSync: any = { 44 | updateProfile: () => { watch.profileUpdated = true; }, 45 | }; 46 | const config = new Config(); 47 | config.roomRules.push({ 48 | action: "deny", 49 | room: "#badroom:example.com" 50 | }); 51 | config.roomRules.push({ 52 | action: "deny", 53 | room: "!evilroom:example.com" 54 | }); 55 | config.portals.enableGateway = true; 56 | config.bridge.domain = "localhost"; 57 | config.bridge.userPrefix = "_prefix_"; 58 | const store = mockStore(); 59 | const gh = new GatewayHandler( 60 | purple, 61 | bridge as any, 62 | config as any, 63 | store, 64 | profileSync, 65 | ); 66 | return {gh, purple, watch, store}; 67 | } 68 | 69 | describe("GatewayHandler", () => { 70 | it("will handle a remote room join sucessfully", async () => { 71 | const {purple, watch} = createGH(); 72 | purple.emit("gateway-joinroom", { 73 | sender: "frogman@frogworld", 74 | protocol_id: dummyProtocol.id, 75 | join_id: "!roomId:localhost", 76 | roomAlias: "#roomAlias:localhost", 77 | room_name: "#roomAlias#localhost@bridge.place", 78 | } as IGatewayJoin); 79 | await watch.remoteJoinPromise; 80 | expect(watch.intent).to.not.be.null; 81 | expect(watch.intent.ensureRegisteredCalled).to.be.true; 82 | expect(watch.intent.userId).to.equal("@_prefix_frogman@frogworld:localhost"); 83 | expect(watch.intent.clientJoinRoomCalledWith.roomString).to.equal("#roomAlias:localhost"); 84 | expect(watch.profileUpdated).to.be.true; 85 | expect(watch.remoteJoin.err).is.null; 86 | expect(watch.remoteJoin.joinId).to.equal("!roomId:localhost"); 87 | expect(watch.remoteJoin.room.roomId).to.equal("!roomAlias:localhost"); 88 | }); 89 | it("will block joining to a gateway if a room is already bridged.", async () => { 90 | const {purple, watch, store} = createGH(); 91 | await store.storeRoom("!roomAlias2:localhost", MROOM_TYPE_GROUP, "remoteId", { 92 | protocol_id: dummyProtocol.id, 93 | room_name: "#roomAlias2#localhost@bridge.place", 94 | } as IRemoteGroupData); 95 | purple.emit("gateway-joinroom", { 96 | sender: "frogman@frogworld", 97 | protocol_id: dummyProtocol.id, 98 | join_id: "!roomId2:localhost", 99 | roomAlias: "#roomAlias2:localhost", 100 | room_name: "#roomAlias2#localhost@bridge.place", 101 | } as IGatewayJoin); 102 | await watch.remoteJoinPromise; 103 | expect(watch.intent).to.not.be.null; 104 | expect(watch.intent.ensureRegisteredCalled).to.be.true; 105 | expect(watch.intent.userId).to.equal("@_prefix_frogman@frogworld:localhost"); 106 | expect(watch.intent.clientJoinRoomCalledWith.roomString).to.equal("#roomAlias2:localhost"); 107 | expect(watch.profileUpdated).to.be.true; 108 | expect(watch.remoteJoin.joinId).to.equal("!roomId2:localhost"); 109 | expect(watch.remoteJoin.err).to.equal( 110 | "This room is already bridged to #roomAlias2#localhost@bridge.place", 111 | ); 112 | }); 113 | it("will block joining to a gateway if the alias is banned.", async () => { 114 | const {purple, watch} = createGH(); 115 | purple.emit("gateway-joinroom", { 116 | sender: "frogman@frogworld", 117 | protocol_id: dummyProtocol.id, 118 | join_id: "!roomId2:localhost", 119 | roomAlias: "#badroom:example.com", 120 | } as IGatewayJoin); 121 | await watch.remoteJoinPromise; 122 | expect(watch.intent).to.not.be.null; 123 | expect(watch.intent.ensureRegisteredCalled).to.be.false; 124 | expect(watch.remoteJoin.err).to.equal( 125 | "This room has been denied", 126 | ); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /test/test_autoregistration.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as Chai from "chai"; 3 | import { AutoRegistration } from "../src/AutoRegistration"; 4 | const expect = Chai.expect; 5 | 6 | describe("AutoRegistration", () => { 7 | it("generateParameters", () => { 8 | const params = AutoRegistration.generateParameters( 9 | { 10 | mxid_test: ":foo", 11 | mxid_sane_test: ":foo", 12 | domain_test: ":foo", 13 | localpart_test: ":foo", 14 | displayname_test: ":foo", 15 | avatar_test: ":foo", 16 | }, 17 | "@towel:frogstar", 18 | { 19 | displayname: "FrogStar!", 20 | avatar_url: "mxc://pond", 21 | }, 22 | ); 23 | expect(params).to.deep.equal({ 24 | domain_test: "frogstar:foo", 25 | localpart_test: "towel:foo", 26 | mxid_test: "@towel:frogstar:foo", 27 | mxid_sane_test: "towel_frogstar:foo", 28 | displayname_test: "FrogStar!:foo", 29 | avatar_test: "mxc://pond:foo", 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/test_config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigValidator } from "matrix-appservice-bridge"; 2 | 3 | const SCHEMA_FILE = `${__dirname}/../config/config.schema.yaml`; 4 | const SAMPLE_FILE = `${__dirname}/../config.sample.yaml`; 5 | describe("configuration files", () =>{ 6 | 7 | it("should load the schema file successfully", () => { 8 | ConfigValidator.fromSchemaFile(SCHEMA_FILE); 9 | }); 10 | 11 | it("should validate the sample config file successfully", () => { 12 | const validator = ConfigValidator.fromSchemaFile(SCHEMA_FILE); 13 | try { 14 | validator.validate(SAMPLE_FILE); 15 | } catch (ex) { 16 | // eslint-disable-next-line no-underscore-dangle,no-console 17 | console.log(ex._validationErrors); 18 | throw Error('Sample config did not validate'); 19 | } 20 | }); 21 | }) -------------------------------------------------------------------------------- /test/test_matrixeventhandler.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as Chai from "chai"; 3 | import { MatrixEventHandler } from "../src/MatrixEventHandler"; 4 | import { mockStore } from "./mocks/store"; 5 | import { Deduplicator } from "../src/Deduplicator"; 6 | import { Config } from "../src/Config"; 7 | import { dummyProtocol } from "./mocks/dummyprotocol"; 8 | import { IStore } from "../src/store/Store"; 9 | import { IRemoteImData, MROOM_TYPE_IM } from "../src/store/Types"; 10 | import { WeakEvent, Request } from "matrix-appservice-bridge"; 11 | const expect = Chai.expect; 12 | 13 | function createRequest(extraEvData: any): Request { 14 | const eventData = { 15 | room_id: "!12345:localhost", 16 | event_id: "$12345:localhost", 17 | sender: "@alice:localhost", 18 | type: "m.room.message", 19 | origin_server_ts: 0, 20 | content: { }, 21 | ...extraEvData, 22 | }; 23 | return new Request({ 24 | id: "requestId", 25 | data: eventData, 26 | }); 27 | } 28 | 29 | function createMEH() { 30 | const purple = { 31 | getUsernameFromMxid: (userId) => { 32 | if (userId === "@definitelyremote:localhost") { 33 | return {username: "definitelyremote", protocol: dummyProtocol}; 34 | } 35 | throw Error("Username didn't match"); 36 | }, 37 | }; 38 | const config = new Config(); 39 | const store = mockStore(); 40 | const gatewayHandler = { 41 | 42 | }; 43 | const bridge = { 44 | getBot: () => ({ 45 | getUserId: () => "@theboss:localhost", 46 | isRemoteUser: (userId: string) => userId === "@definitelyremote:localhost", 47 | }), 48 | getIntent: (userId: string) => ({ 49 | opts: { 50 | 51 | }, 52 | join: () => { /* empty */ }, 53 | matrixClient: { 54 | getUserId: () => userId, 55 | doRequest: () => Promise.resolve({ 56 | chunk: [{ 57 | type: "m.room.message", 58 | event_id: "$1:localhost", 59 | }, 60 | { 61 | type: "m.room.member", 62 | sender: userId, 63 | event_id: "$2:localhost", 64 | }, 65 | { 66 | type: "m.room.message", 67 | event_id: "$3:localhost", 68 | }], 69 | }), 70 | }, 71 | }), 72 | }; 73 | const meh = new MatrixEventHandler( 74 | purple as any, 75 | store, 76 | new Deduplicator(), 77 | config, 78 | gatewayHandler as any, 79 | bridge as any, 80 | {} as any, 81 | ); 82 | return {meh, store}; 83 | } 84 | 85 | describe("MatrixEventHandler", () => { 86 | describe("onEvent", () => { 87 | let meh: MatrixEventHandler; 88 | let store: IStore; 89 | beforeEach(() => { 90 | const res = createMEH(); 91 | meh = res.meh; 92 | store = res.store; 93 | }); 94 | it("handle new invite for bot", async () => { 95 | let handleInviteForBotCalledWith; 96 | (meh as any).handleInviteForBot = (ev) => handleInviteForBotCalledWith = ev; 97 | await meh.onEvent(createRequest({ 98 | type: "m.room.member", 99 | content: { 100 | membership: "invite", 101 | }, 102 | event_id: "$botinviteevent", 103 | state_key: "@theboss:localhost", 104 | })); 105 | expect(handleInviteForBotCalledWith.event_id).to.be.equal("$botinviteevent"); 106 | }); 107 | it("handle new invite for ghost", async () => { 108 | let messagesHandled = 0; 109 | (meh as any).getAccountForMxid = (ev) => ({ 110 | acct: { 111 | protocol: dummyProtocol, 112 | }, 113 | }); 114 | (meh as any).handleImMessage = (ev) => { 115 | messagesHandled++; 116 | }; 117 | await meh.onEvent(createRequest({ 118 | type: "m.room.member", 119 | content: { 120 | membership: "invite", 121 | is_direct: true, 122 | }, 123 | event_id: "$ghostinviteevent", 124 | state_key: "@definitelyremote:localhost", 125 | })); 126 | expect(messagesHandled).to.equal(1); 127 | const storeEntry = await store.getGroupRoomByRemoteData({ 128 | recipient: "definitelyremote", 129 | matrixUser: "@alice:localhost", 130 | protocol_id: dummyProtocol.id, 131 | } as IRemoteImData); 132 | expect(storeEntry).to.not.be.null; 133 | expect(storeEntry?.matrix?.getId()).to.equal("!12345:localhost"); 134 | expect(storeEntry?.matrix?.get("type")).to.equal(MROOM_TYPE_IM); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /test/test_profilesync.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as Chai from "chai"; 3 | import { mockStore } from "./mocks/store"; 4 | import { ProfileSync } from "../src/ProfileSync"; 5 | import { Config } from "../src/Config"; 6 | import { PurpleProtocol } from "../src/purple/PurpleProtocol"; 7 | const expect = Chai.expect; 8 | 9 | const dummyProtocol = new PurpleProtocol({ 10 | id: "prpl-dummy", 11 | name: "Dummy", 12 | homepage: undefined, 13 | summary: undefined, 14 | }); 15 | 16 | function createProfileSync(userInfo?: Record) { 17 | const values = { 18 | displayname: "", 19 | avatarUrl: "", 20 | userId: "", 21 | uri: "", 22 | uploadedData: undefined, 23 | }; 24 | const store = mockStore(); 25 | const bridge = { 26 | getIntent: (userId) => { 27 | values.userId = userId; 28 | return { 29 | setDisplayName: (displayname) => { 30 | values.displayname = displayname; 31 | }, 32 | setAvatarUrl: (avatarUrl) => { 33 | values.avatarUrl = avatarUrl; 34 | }, 35 | ensureProfile: (displayname, avatarUrl) => { 36 | values.displayname = displayname; 37 | values.avatarUrl = avatarUrl; 38 | }, 39 | uploadContent: (data) => { 40 | values.uploadedData = data; 41 | return "mxc://example.com/foobar"; 42 | }, 43 | }; 44 | }, 45 | }; 46 | const config = new Config(); 47 | config.bridge.userPrefix = "_bifrost_"; 48 | config.bridge.domain = "localhost"; 49 | const account = { 50 | getBuddy: () => undefined, 51 | getUserInfo: (senderID) => userInfo, 52 | getAvatarBuffer: (uri, senderId) => { 53 | values.uri = uri; 54 | return Buffer.from("12345"); 55 | }, 56 | }; 57 | return { 58 | profileSync: new ProfileSync( 59 | bridge as any, 60 | config, 61 | store, 62 | ), 63 | store, 64 | account, 65 | values, 66 | }; 67 | } 68 | 69 | describe("ProfileSync", () => { 70 | it("will sync one profile without any UserInfo", async () => { 71 | const time = Date.now(); 72 | const {profileSync, account, values, store} = createProfileSync(); 73 | await profileSync.updateProfile(dummyProtocol, "alice@foobar.com", account as any, false); 74 | expect(values.displayname).to.equal("alice@foobar.com"); 75 | expect(values.userId).to.equal("@_bifrost_dummy_alice=40foobar.com:localhost"); 76 | const matrixUser = await store.getMatrixUser(values.userId); 77 | expect(matrixUser?.get("last_check")).to.be.above(time); 78 | }); 79 | it("can sync one profile without useful UserInfo", async () => { 80 | const time = Date.now(); 81 | const {profileSync, account, values, store} = createProfileSync({ 82 | foo: "bar", 83 | }); 84 | await profileSync.updateProfile(dummyProtocol, "alice@foobar.com", account as any, false); 85 | expect(values.displayname).to.equal("alice@foobar.com"); 86 | expect(values.userId).to.equal("@_bifrost_dummy_alice=40foobar.com:localhost"); 87 | const matrixUser = await store.getMatrixUser(values.userId); 88 | expect(matrixUser?.get("last_check")).to.be.above(time); 89 | }); 90 | it("can sync one profile with a nickname", async () => { 91 | const time = Date.now(); 92 | const {profileSync, account, values, store} = createProfileSync({ 93 | Nickname: "SuperAlice", 94 | }); 95 | await profileSync.updateProfile(dummyProtocol, "alice@foobar.com", account as any, false); 96 | expect(values.displayname).to.equal("SuperAlice"); 97 | expect(values.userId).to.equal("@_bifrost_dummy_alice=40foobar.com:localhost"); 98 | const matrixUser = await store.getMatrixUser(values.userId); 99 | expect(matrixUser?.get("last_check")).to.be.above(time); 100 | }); 101 | it("can sync one profile with a avatar ", async () => { 102 | const time = Date.now(); 103 | const {profileSync, account, values, store} = createProfileSync({ 104 | Avatar: "http://example.com/myamazingavatar.png", 105 | }); 106 | await profileSync.updateProfile(dummyProtocol, "alice@foobar.com", account as any, false); 107 | expect(values.displayname).to.equal("alice@foobar.com"); 108 | expect(values.userId).to.equal("@_bifrost_dummy_alice=40foobar.com:localhost"); 109 | const matrixUser = await store.getMatrixUser(values.userId); 110 | expect(matrixUser?.get("last_check")).to.be.above(time); 111 | expect(values.avatarUrl).to.be.equal("mxc://example.com/foobar"); 112 | }); 113 | it("will skip the second profile update", async () => { 114 | const time = Date.now(); 115 | const {profileSync, account, values, store} = createProfileSync({}); 116 | await profileSync.updateProfile(dummyProtocol, "aconvo@foobar.com/FOO!$BAR", account as any, false); 117 | const matrixUser = await store.getMatrixUser(values.userId); 118 | expect(matrixUser?.get("last_check")).to.be.above(time); 119 | const lastTime = matrixUser?.get("last_check"); 120 | await profileSync.updateProfile(dummyProtocol, "aconvo@foobar.com/FOO!$BAR", account as any, false); 121 | const matrixUserTwo = await store.getMatrixUser(values.userId); 122 | expect(matrixUserTwo?.get("last_check")).to.be.equal(lastTime); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /test/test_protohacks.ts: -------------------------------------------------------------------------------- 1 | import * as Chai from "chai"; 2 | import { PurpleProtocol } from "../src/purple/PurpleProtocol"; 3 | const expect = Chai.expect; 4 | 5 | const dummyProtocol = new PurpleProtocol({ 6 | id: "prpl-dummy", 7 | name: "Dummy", 8 | homepage: undefined, 9 | summary: undefined, 10 | }); 11 | 12 | const XMPP = new PurpleProtocol({ 13 | id: "prpl-jabber", 14 | name: "XMPP", 15 | homepage: undefined, 16 | summary: undefined, 17 | }); 18 | 19 | // describe("ProtoHacks", () => { 20 | // describe("getSenderId", () => { 21 | // // it("XMPP (group-chat) should not modify senderId", () => { 22 | // // expect( 23 | // // ProtoHacks.getSenderId(XMPP, "testroom@conference.localhost/User1", true) 24 | // // ).to.equal("testroom@conference.localhost/User1"); 25 | // // }); 26 | // // it("XMPP (im) should modify senderId", () => { 27 | // // expect( 28 | // // ProtoHacks.getSenderId(XMPP, "testuser1@localhost/somerandoclient", true) 29 | // // ).to.equal("testuser1@localhost"); 30 | // // }); 31 | // // it("other protocols should not modify senderId", () => { 32 | // // expect( 33 | // // ProtoHacks.getSenderId(dummyProtocol, "abcdef", true), 34 | // // ).to.equal("abcdef"); 35 | // // expect( 36 | // // ProtoHacks.getSenderId(dummyProtocol, "abcdef", false), 37 | // // ).to.equal("abcdef"); 38 | // // }); 39 | // }); 40 | // }); 41 | -------------------------------------------------------------------------------- /test/test_roomsync.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as Chai from "chai"; 3 | import { PurpleProtocol } from "../src/purple/PurpleProtocol"; 4 | import { RoomSync } from "../src/RoomSync"; 5 | import { Deduplicator } from "../src/Deduplicator"; 6 | import { MROOM_TYPE_GROUP, IRemoteGroupData } from "../src/store/Types"; 7 | import { mockStore } from "./mocks/store"; 8 | import { AppServiceBot, Intent, RoomBridgeStoreEntry } from "matrix-appservice-bridge"; 9 | const expect = Chai.expect; 10 | 11 | const dummyProtocol = new PurpleProtocol({ 12 | id: "prpl-dummy", 13 | name: "Dummy", 14 | homepage: undefined, 15 | summary: undefined, 16 | }); 17 | 18 | function createBotAndIntent() { 19 | const bot = { 20 | getJoinedMembers: async () => ({ 21 | "@foo:bar": {}, 22 | "@remote_foo:bar": {}, 23 | }), 24 | isRemoteUser: (userId: string) => userId.startsWith("@remote"), 25 | getUserId: () => "@bot:localhost", 26 | } as unknown as AppServiceBot; 27 | const intent = { 28 | 29 | } as unknown as Intent; 30 | return {bot, intent}; 31 | } 32 | 33 | let remoteJoins: any[]; 34 | 35 | function createRoomSync(intent, rooms: RoomBridgeStoreEntry[] = []) { 36 | remoteJoins = []; 37 | // Create dummy objects, only implement needed stuff. 38 | const purple = { 39 | on: (ev: string, func: () => void) => { 40 | // No-op 41 | }, 42 | getProtocol: () => true, 43 | }; 44 | 45 | const gateway = { 46 | initialMembershipSync: (roomEntry) => remoteJoins.push(roomEntry), 47 | }; 48 | 49 | const store = mockStore(); 50 | 51 | // { 52 | // get: (key: string) => { 53 | // return { 54 | // username: "foobar", 55 | // protocolId: dummyProtocol.id, 56 | // }[key]; 57 | // }, 58 | // getId: () => "foobar", 59 | // } 60 | 61 | return { 62 | rs: new RoomSync(purple as any, store, new Deduplicator(), gateway as any, intent), 63 | store, 64 | }; 65 | } 66 | 67 | describe("RoomSync", () => { 68 | it("constructs", () => { 69 | const rs = createRoomSync(null); 70 | }); 71 | it("should sync one room for one user", async () => { 72 | const {bot, intent} = createBotAndIntent(); 73 | const {rs, store} = createRoomSync(intent); 74 | await store.storeRoom("!abc:foobar", MROOM_TYPE_GROUP, "foobar", { 75 | type: MROOM_TYPE_GROUP, 76 | protocol_id: dummyProtocol.id, 77 | room_name: "abc", 78 | } as IRemoteGroupData); 79 | await store.storeAccount("@foo:bar", dummyProtocol, "foobar"); 80 | await rs.sync(bot as any); 81 | expect(rs.getMembershipForUser("prpl-dummy://foobar")).to.deep.equal([ 82 | { 83 | membership: "join", 84 | params: {}, 85 | room_name: "abc", 86 | }, 87 | ]); 88 | }); 89 | it("should sync remote users for gateways", async () => { 90 | const {bot, intent} = createBotAndIntent(); 91 | const {rs, store} = createRoomSync(intent); 92 | await store.storeRoom("!abc:foobar", MROOM_TYPE_GROUP, "foobar", { 93 | type: MROOM_TYPE_GROUP, 94 | protocol_id: dummyProtocol.id, 95 | room_name: "abc", 96 | gateway: true, 97 | } as IRemoteGroupData); 98 | await store.storeAccount("@foo:bar", dummyProtocol, "foobar"); 99 | await rs.sync(bot as any); 100 | expect(rs.getMembershipForUser("prpl-dummy://foobar")).to.not.exist; 101 | expect(remoteJoins[0].id).to.equal("!abc:foobar foobar"); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/test_store.ts: -------------------------------------------------------------------------------- 1 | import * as Chai from "chai"; 2 | import { IStore } from "../src/store/Store"; 3 | import { mockStore } from "./mocks/store"; 4 | import { PurpleProtocol } from "../src/purple/PurpleProtocol"; 5 | import { MatrixUser, RemoteUser } from "matrix-appservice-bridge"; 6 | const expect = Chai.expect; 7 | 8 | let store: IStore; 9 | 10 | const dummyProtocol = new PurpleProtocol({ 11 | id: "prpl-dummy", 12 | name: "Dummy", 13 | homepage: undefined, 14 | summary: undefined, 15 | }); 16 | 17 | describe("Store", () => { 18 | beforeEach(() => { 19 | store = mockStore(); 20 | }); 21 | it("should not overrwrite custom keys", async () => { 22 | // First, store a user. 23 | await store.storeGhost( 24 | "@_xmpp_ghosty:localhost", dummyProtocol, 25 | "ghostly", { 26 | myCustomKey: 1000, 27 | }, 28 | ); 29 | // Now let's set some data about that user 30 | const mxUser = new MatrixUser("@_xmpp_ghosty:localhost"); 31 | mxUser.set("anotherCustomKey", 5000); 32 | await store.setMatrixUser(mxUser); 33 | // Store them again 34 | await store.storeGhost( 35 | "@_xmpp_ghosty:localhost", dummyProtocol, 36 | "ghostly", { 37 | myCustomKey: 1000, 38 | }, 39 | ); 40 | // Now get the user 41 | const fetchedUser = await store.getMatrixUser("@_xmpp_ghosty:localhost"); 42 | expect(fetchedUser.userId).to.equal("@_xmpp_ghosty:localhost"); 43 | expect(fetchedUser.get("anotherCustomKey")).to.equal(5000); 44 | }); 45 | it("should update an mxid when the account changes", async () => { 46 | // First, store a user. 47 | await store.storeGhost( 48 | "@_xmpp_old_ghosty:localhost", dummyProtocol, 49 | "ghostly", { 50 | myCustomKey: 1000, 51 | }, 52 | ); 53 | // Store them again, but with a different mxid 54 | await store.storeGhost( 55 | "@_xmpp_ghosty:localhost", dummyProtocol, 56 | "ghostly", { 57 | myCustomKey: 1000, 58 | }, 59 | ); 60 | const remotes = await store.getRemoteUsersFromMxId("@_xmpp_ghosty:localhost"); 61 | expect(remotes[0]).to.exist; 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/test_util.ts: -------------------------------------------------------------------------------- 1 | import * as Chai from "chai"; 2 | import { Util } from "../src/Util"; 3 | import { PurpleProtocol } from "../src/purple/PurpleProtocol"; 4 | import { XMPP_PROTOCOL } from "../src/xmppjs/XJSInstance"; 5 | const expect = Chai.expect; 6 | 7 | describe("Util", () => { 8 | describe("createRemoteId", () => { 9 | it("should create a simple remoteId", () => { 10 | expect(Util.createRemoteId("prpl-protocol", "simple")).to.equal("prpl-protocol://simple"); 11 | }); 12 | }); 13 | describe("getMxIdForProtocol", () => { 14 | const protocol = new PurpleProtocol({ 15 | id: "prpl-protocol", 16 | name: "Fake Protocol", 17 | homepage: undefined, 18 | summary: undefined, 19 | }); 20 | it("should create a simple userId", () => { 21 | const mxUser = protocol.getMxIdForProtocol("simple", "example.com", "_purple_"); 22 | expect( 23 | mxUser.getId(), 24 | ).to.equal("@_purple_protocol_simple:example.com"); 25 | }); 26 | it("should create a sensible userId from a sender containing url parts", () => { 27 | const mxUser = protocol.getMxIdForProtocol("fred@banana.com", "example.com", "_purple_"); 28 | expect( 29 | mxUser.getId(), 30 | ).to.equal("@_purple_protocol_fred=40banana.com:example.com"); 31 | }); 32 | it("should create a sensible userId from a sender containing a matrix userid", () => { 33 | const mxUser = protocol.getMxIdForProtocol("@fred:banana.com", "example.com", "_purple_"); 34 | expect( 35 | mxUser.getId(), 36 | ).to.equal("@_purple_protocol_=40fred=3abanana.com:example.com"); 37 | }); 38 | it("should create a sensible userId for an xmpp jid", () => { 39 | const mxUser = XMPP_PROTOCOL.getMxIdForProtocol("frogman@frogplanet.com", "example.com", "_xmpp_"); 40 | expect( 41 | mxUser.getId(), 42 | ).to.equal("@_xmpp_frogman=40frogplanet.com:example.com"); 43 | }); 44 | it("should create a sensible userId for an xmpp jid with a resource", () => { 45 | const mxUser = XMPP_PROTOCOL.getMxIdForProtocol( 46 | "frogman@frogplanet.com/frogdevice", "example.com", "_xmpp_", 47 | ); 48 | expect( 49 | mxUser.getId(), 50 | ).to.equal("@_xmpp_frogdevice=2ffrogman=40frogplanet.com:example.com"); 51 | }); 52 | it("should create a sensible userId for an xmpp jid with a resource with special chars", () => { 53 | const mxUser = XMPP_PROTOCOL.getMxIdForProtocol( 54 | "frogman@frogplanet.com/Frog!%$£ device", "example.com", "_xmpp_", 55 | ); 56 | expect( 57 | mxUser.getId(), 58 | ).to.equal("@_xmpp_Frog=21=25=24=a3=20device=2ffrogman=40frogplanet.com:example.com"); 59 | }); 60 | }); 61 | describe("passwordGen", () => { 62 | it("should create a printable password", () => { 63 | const passwd = Util.passwordGen(64); 64 | expect(passwd.length).to.be.at.least(64); 65 | for (const c of passwd) { 66 | const i = c.charCodeAt(0); 67 | if (i < 32 && i > 126) { 68 | throw Error("Password is not printable"); 69 | } 70 | } 71 | }); 72 | }); 73 | describe("sanitizeProperties", () => { 74 | it("should sanitize properties", () => { 75 | expect(Util.sanitizeProperties({ 76 | "my.wonderful.property": "foo", 77 | "normal_property": "bar", 78 | })).to.deep.equal({ 79 | "my·wonderful·property": "foo", 80 | "normal_property": "bar", 81 | }); 82 | }); 83 | }); 84 | describe("desanitizeProperties", () => { 85 | it("should desanitize properties", () => { 86 | expect(Util.desanitizeProperties({ 87 | "my·wonderful·property": "foo", 88 | "normal_property": "bar", 89 | })).to.deep.equal({ 90 | "my.wonderful.property": "foo", 91 | "normal_property": "bar", 92 | }); 93 | }); 94 | }); 95 | describe("unescapeUserId", () => { 96 | it("should unescape QF encoding", () => { 97 | expect( 98 | Util.unescapeUserId("Hello=a3=21=25=26=20World"), 99 | ).to.equal("Hello£!%& World"); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/xmppjs/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { jid } from "@xmpp/jid"; 2 | 3 | export const XMPP_CHAT_NAME = "mychatname#matrix.org"; 4 | export const XMPP_MEMBER_JID = jid("xmpp_bob", "xmpp.example.com", "myresource"); 5 | export const XMPP_MEMBER_JID_STRIPPED = jid("xmpp_bob", "xmpp.example.com"); 6 | export const XMPP_MEMBER_JID_SECOND_DEVICE = jid("xmpp_bob", "xmpp.example.com", "myresource2"); 7 | export const XMPP_MEMBER_ANONYMOUS = jid(XMPP_CHAT_NAME, "xmpp.example.com", "bob"); 8 | export const XMPP_MEMBER_MXID = "@_x_xmpp_bob:matrix.example.com"; 9 | 10 | export const MATRIX_MEMBER_MXID = "@alice:matrix.example.com"; 11 | export const MATRIX_MEMBER_ANONYMOUS = jid(XMPP_CHAT_NAME, "xmpp.example.com", "alice"); 12 | export const MATRIX_ALIAS = "#mychatname:matrix.org" -------------------------------------------------------------------------------- /test/xmppjs/test_GatewayMUCMembership.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { GatewayMUCMembership } from "../../src/xmppjs/GatewayMUCMembership"; 3 | import { XMPP_CHAT_NAME, MATRIX_MEMBER_ANONYMOUS, MATRIX_MEMBER_MXID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_JID, XMPP_MEMBER_JID_SECOND_DEVICE, XMPP_MEMBER_JID_STRIPPED, XMPP_MEMBER_MXID } from "./fixtures"; 4 | 5 | describe("GatewayMUCMembership", () => { 6 | let members: GatewayMUCMembership; 7 | beforeEach(() => { 8 | members = new GatewayMUCMembership(); 9 | }) 10 | describe("adding members", () => { 11 | it("can add a XMPP member", () => { 12 | const firstDevice = members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 13 | expect(firstDevice).to.be.true; 14 | }); 15 | it("can add another device for the same XMPP member", () => { 16 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 17 | const firstDevice = members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID_SECOND_DEVICE, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 18 | expect(firstDevice).to.be.false; 19 | }); 20 | it("can add a Matrix member", () => { 21 | const firstDevice = members.addMatrixMember(XMPP_CHAT_NAME, MATRIX_MEMBER_MXID, XMPP_MEMBER_ANONYMOUS); 22 | expect(firstDevice).to.be.true; 23 | }); 24 | it("can add another device for the same Matrix member", () => { 25 | members.addMatrixMember(XMPP_CHAT_NAME, MATRIX_MEMBER_MXID, MATRIX_MEMBER_ANONYMOUS); 26 | const firstDevice = members.addMatrixMember(XMPP_CHAT_NAME, MATRIX_MEMBER_MXID, MATRIX_MEMBER_ANONYMOUS); 27 | expect(firstDevice).to.be.false; 28 | }); 29 | }); 30 | describe("removing members", () => { 31 | it("can remove a XMPP member", () => { 32 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 33 | const lastDevice = members.removeXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID.toString()); 34 | expect(lastDevice).to.be.true; 35 | }); 36 | it("can add two devices, and remove one", () => { 37 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 38 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID_SECOND_DEVICE, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 39 | let lastDevice = members.removeXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID.toString()); 40 | expect(lastDevice).to.be.false; 41 | lastDevice = members.removeXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID_SECOND_DEVICE.toString()); 42 | expect(lastDevice).to.be.true; 43 | }); 44 | it("can add two devices, and remove all if the JID is stripped", () => { 45 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 46 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID_SECOND_DEVICE, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 47 | const lastDevice = members.removeXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID_STRIPPED); 48 | expect(lastDevice).to.be.true; 49 | }); 50 | it("can remove a Matrix member", () => { 51 | members.addMatrixMember(XMPP_CHAT_NAME, MATRIX_MEMBER_MXID, MATRIX_MEMBER_ANONYMOUS); 52 | const removed = members.removeMatrixMember(XMPP_CHAT_NAME, MATRIX_MEMBER_MXID); 53 | expect(removed).to.be.true; 54 | }); 55 | it("will return false if the Matrix member was not removed", () => { 56 | const removed = members.removeMatrixMember(XMPP_CHAT_NAME, MATRIX_MEMBER_MXID); 57 | expect(removed).to.be.false; 58 | }); 59 | 60 | }); 61 | describe("finding members", () => { 62 | it("can find an XMPP member by real JID", () => { 63 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 64 | const member = members.getXmppMemberByRealJid(XMPP_CHAT_NAME, XMPP_MEMBER_JID); 65 | expect(member?.realJid.toString()).to.be.equal(XMPP_MEMBER_JID_STRIPPED.toString()); 66 | expect(member?.anonymousJid.toString()).to.be.equal(XMPP_MEMBER_ANONYMOUS.toString()); 67 | expect(member?.matrixId).to.be.equal(XMPP_MEMBER_MXID); 68 | expect(member?.devices.size).to.equal(1); 69 | expect(member?.devices.values().next().value).to.equal(XMPP_MEMBER_JID.toString()) 70 | }); 71 | it("will not find an XMPP member by a second device JID", () => { 72 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 73 | expect(members.getXmppMemberByDevice(XMPP_CHAT_NAME, XMPP_MEMBER_JID)).to.exist; 74 | expect(members.getXmppMemberByDevice(XMPP_CHAT_NAME, XMPP_MEMBER_JID_SECOND_DEVICE)).to.be.undefined; 75 | }); 76 | it("will not find a XMPP member by mxId", () => { 77 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 78 | const member = members.getXmppMemberByMatrixId(XMPP_CHAT_NAME, XMPP_MEMBER_MXID); 79 | expect(member?.realJid.toString()).to.be.equal(XMPP_MEMBER_JID_STRIPPED.toString()); 80 | expect(member?.anonymousJid.toString()).to.be.equal(XMPP_MEMBER_ANONYMOUS.toString()); 81 | expect(member?.matrixId).to.be.equal(XMPP_MEMBER_MXID); 82 | expect(member?.devices.size).to.equal(1); 83 | expect(member?.devices.values().next().value).to.equal(XMPP_MEMBER_JID.toString()) 84 | }); 85 | it("can find a Matrix member by mxId", () => { 86 | members.addMatrixMember(XMPP_CHAT_NAME, MATRIX_MEMBER_MXID, MATRIX_MEMBER_ANONYMOUS); 87 | const member = members.getMatrixMemberByMatrixId(XMPP_CHAT_NAME, MATRIX_MEMBER_MXID); 88 | expect(member?.type).to.equal("matrix"); 89 | expect(member?.anonymousJid.toString()).to.be.equal(MATRIX_MEMBER_ANONYMOUS.toString()); 90 | expect(member?.matrixId).to.be.equal(MATRIX_MEMBER_MXID); 91 | }); 92 | }); 93 | }) -------------------------------------------------------------------------------- /test/xmppjs/test_GatewayStateResolve.ts: -------------------------------------------------------------------------------- 1 | import { jid } from "@xmpp/jid"; 2 | import { expect } from "chai"; 3 | import { PresenceAffiliation, PresenceRole, StzaPresenceItem } from "../../src/xmppjs/Stanzas"; 4 | import { MatrixMembershipEvent } from "../../src/MatrixTypes"; 5 | import { GatewayMUCMembership } from "../../src/xmppjs/GatewayMUCMembership"; 6 | import { GatewayStateResolve } from "../../src/xmppjs/GatewayStateResolve"; 7 | import { XMPP_CHAT_NAME, MATRIX_MEMBER_ANONYMOUS, MATRIX_MEMBER_MXID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_JID, XMPP_MEMBER_MXID } from "./fixtures"; 8 | 9 | 10 | const generateMember = (membership: "join"|"leave", mxid: string): MatrixMembershipEvent => ({ 11 | sender: mxid, 12 | state_key: mxid, 13 | content: { 14 | membership, 15 | }, 16 | room_id: "!foo:bar", 17 | origin_server_ts: 123456, 18 | event_id: "$abc:def", 19 | type: "m.room.member", 20 | }); 21 | 22 | describe("GatewayStateResolve", () => { 23 | let members: GatewayMUCMembership; 24 | beforeEach(() => { 25 | members = new GatewayMUCMembership(); 26 | }); 27 | it("will ignore a join for a room without XMPP members", () => { 28 | const res = GatewayStateResolve.resolveMatrixStateToXMPP(XMPP_CHAT_NAME, members, generateMember("join", MATRIX_MEMBER_MXID)); 29 | expect(res).to.have.lengthOf(0); 30 | }); 31 | it("will handle a Matrix join", () => { 32 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 33 | const res = GatewayStateResolve.resolveMatrixStateToXMPP(XMPP_CHAT_NAME, members, generateMember("join", MATRIX_MEMBER_MXID)); 34 | expect(res).to.have.lengthOf(1); 35 | const presence = res[0] as StzaPresenceItem; 36 | expect(presence.to).to.equal(XMPP_MEMBER_JID.toString()); 37 | expect(presence.from).to.equal(XMPP_CHAT_NAME + "/" + MATRIX_MEMBER_MXID); 38 | expect(presence.role).to.equal(PresenceRole.Participant); 39 | expect(presence.affiliation).to.equal(PresenceAffiliation.Member); 40 | }); 41 | it("will ignore a leave for a room without XMPP members", () => { 42 | members.addMatrixMember(XMPP_CHAT_NAME, MATRIX_MEMBER_MXID, MATRIX_MEMBER_ANONYMOUS); 43 | const res = GatewayStateResolve.resolveMatrixStateToXMPP(XMPP_CHAT_NAME, members, generateMember("leave", MATRIX_MEMBER_MXID)); 44 | expect(res).to.have.lengthOf(0); 45 | }); 46 | it("will ignore a leave for a room if the matrix user wasn't joined", () => { 47 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 48 | const res = GatewayStateResolve.resolveMatrixStateToXMPP(XMPP_CHAT_NAME, members, generateMember("leave", MATRIX_MEMBER_MXID)); 49 | expect(res).to.have.lengthOf(0); 50 | }); 51 | it("will ignore a leave for a room without XMPP members", () => { 52 | members.addXmppMember(XMPP_CHAT_NAME, XMPP_MEMBER_JID, XMPP_MEMBER_ANONYMOUS, XMPP_MEMBER_MXID); 53 | members.addMatrixMember(XMPP_CHAT_NAME, MATRIX_MEMBER_MXID, MATRIX_MEMBER_ANONYMOUS); 54 | const res = GatewayStateResolve.resolveMatrixStateToXMPP(XMPP_CHAT_NAME, members, generateMember("leave", MATRIX_MEMBER_MXID)); 55 | expect(res).to.have.lengthOf(1); 56 | const presence = res[0] as StzaPresenceItem; 57 | expect(presence.to).to.equal(XMPP_MEMBER_JID.toString()); 58 | expect(presence.from).to.equal(MATRIX_MEMBER_ANONYMOUS.toString()); 59 | expect(presence.role).to.equal(PresenceRole.None); 60 | expect(presence.affiliation).to.equal(PresenceAffiliation.Member); 61 | }); 62 | }) -------------------------------------------------------------------------------- /test/xmppjs/test_Stanzas.ts: -------------------------------------------------------------------------------- 1 | import * as Chai from "chai"; 2 | import { StzaPresenceItem, StzaPresenceError, StzaMessageSubject, 3 | StzaMessage, StzaPresencePart, StzaPresenceKick, SztaIqError, StzaIqDiscoInfo } from "../../src/xmppjs/Stanzas"; 4 | import { XMPPFeatures } from "../../src/xmppjs/XMPPConstants"; 5 | import { assertXML } from "./util"; 6 | const expect = Chai.expect; 7 | 8 | describe("Stanzas", () => { 9 | describe("StzaPresenceItem", () => { 10 | it("should create a valid stanza", () => { 11 | const xml = new StzaPresenceItem("foo@bar", "baz@bar", "someid").xml; 12 | assertXML(xml); 13 | expect(xml).to.equal( 14 | "" + 15 | "" 16 | + "", 17 | ); 18 | }); 19 | }); 20 | describe("StzaPresenceError", () => { 21 | it("should create a valid stanza", () => { 22 | const xml = new StzaPresenceError("foo@bar", "baz@bar", "someid", "baz2@bar", "cancel", "inner-error").xml; 23 | assertXML(xml); 24 | expect(xml).to.equal( 25 | "" 27 | + "", 28 | ); 29 | }); 30 | }); 31 | describe("StzaPresencePart", () => { 32 | it("should create a valid stanza", () => { 33 | const xml = new StzaPresencePart("foo@bar", "baz@bar").xml; 34 | assertXML(xml); 35 | expect(xml).to.equal( 36 | "", 37 | ); 38 | }); 39 | }); 40 | describe("StzaPresenceKick", () => { 41 | it("should create a valid stanza", () => { 42 | const xml = new StzaPresenceKick("foo@bar", "baz@bar", "reasonable reason", "Kicky", true).xml; 43 | assertXML(xml); 44 | expect(xml).to.equal( 45 | `` 46 | + "" 47 | + "" 48 | + "reasonable reason", 49 | ); 50 | }); 51 | }); 52 | describe("StzaMessage", () => { 53 | it("should create a valid stanza for a simple plain message", () => { 54 | const stanza = new StzaMessage("foo@bar", "baz@bar", "someid", "groupchat"); 55 | stanza.body = "Viva la matrix̭"; 56 | assertXML(stanza.xml); 57 | expect(stanza.xml).to.equal( 58 | "" 59 | + "Viva la matrix̭", 60 | ); 61 | }); 62 | it("should create a valid stanza for a html message", () => { 63 | const stanza = new StzaMessage("foo@bar", "baz@bar", "someid", "groupchat"); 64 | stanza.body = "Viva la matrix̭"; 65 | stanza.html = "

Viva la matrix̭

"; 66 | assertXML(stanza.xml); 67 | expect(stanza.xml).to.equal( 68 | "

" 69 | + "Viva la matrix̭

Viva la matrix̭" 70 | + "
", 71 | ); 72 | }); 73 | it("should create a valid stanza for a message with attachments", () => { 74 | const stanza = new StzaMessage("foo@bar", "baz@bar", "someid", "groupchat"); 75 | stanza.body = "Viva la matrix̭"; 76 | stanza.html = "

Viva la matrix̭

"; 77 | stanza.attachments = ["http://matrix.org"]; 78 | assertXML(stanza.xml); 79 | expect(stanza.xml).to.equal( 80 | "

" 81 | + "Viva la matrix̭

http://matrix.org" 82 | + "http://matrix.org" 83 | + "
", 84 | ); 85 | }); 86 | }); 87 | describe("StzaMessageSubject", () => { 88 | it("should create a valid stanza", () => { 89 | const xml = new StzaMessageSubject("foo@bar", "baz@bar", "someid", "This is a subject").xml; 90 | assertXML(xml); 91 | expect(xml).to.equal( 92 | "" 93 | + "This is a subject", 94 | ); 95 | }); 96 | }); 97 | describe("SztaIqError", () => { 98 | it("should create a an error", () => { 99 | const xml = new SztaIqError("foo@bar", "baz@bar", "someid", "cancel", null, "not-acceptable", "foo").xml; 100 | assertXML(xml); 101 | expect(xml).to.equal( 102 | "" + 103 | "" + 104 | "", 105 | ); 106 | }); 107 | }); 108 | it("should create a an error with custom text", () => { 109 | const xml = new SztaIqError("foo@bar", "baz@bar", "someid", "cancel", null, "not-acceptable", "foo", "Something isn't right").xml; 110 | assertXML(xml); 111 | expect(xml).to.equal( 112 | "" + 113 | "" + 114 | `Something isn't right` + 115 | "", 116 | ); 117 | }); 118 | describe("StzaIqDiscoInfo", () => { 119 | it("should create a valid stanza", () => { 120 | const xml = new StzaIqDiscoInfo("foo@bar", "baz@bar", "someid").xml; 121 | assertXML(xml); 122 | expect(xml).to.equal( 123 | ``, 124 | ); 125 | }); 126 | it("should create a valid hash", () => { 127 | const userDiscoInfo = new StzaIqDiscoInfo("foo@bar", "baz@bar", "someid"); 128 | userDiscoInfo.identity.add({category: "client", type: "bridge", name: "matrix-bifrost"}) 129 | userDiscoInfo.feature.add(XMPPFeatures.DiscoInfo); 130 | userDiscoInfo.feature.add(XMPPFeatures.Jingle); 131 | userDiscoInfo.feature.add(XMPPFeatures.JingleFileTransferV4); 132 | userDiscoInfo.feature.add(XMPPFeatures.JingleFileTransferV5); 133 | userDiscoInfo.feature.add(XMPPFeatures.JingleIBB); 134 | userDiscoInfo.feature.add(XMPPFeatures.XHTMLIM); 135 | userDiscoInfo.feature.add(XMPPFeatures.ChatStates); 136 | expect(userDiscoInfo.hash).to.equal('YvWxpAh3qsnZdItZNak8ruVh+Gs='); 137 | }) 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /test/xmppjs/test_XHTMLIM.ts: -------------------------------------------------------------------------------- 1 | import * as Chai from "chai"; 2 | import { XHTMLIM } from "../../src/xmppjs/XHTMLIM"; 3 | import { assertXML } from "./util"; 4 | 5 | const expect = Chai.expect; 6 | 7 | describe("XHTMLIM", () => { 8 | it("should not change compliant messages", () => { 9 | expect( 10 | XHTMLIM.HTMLToXHTML( 11 | "

Hello world

", 12 | ), 13 | ).to.equal( 14 | "

Hello world

", 15 | ); 16 | }); 17 | it("should transform a simple text message", () => { 18 | expect( 19 | XHTMLIM.HTMLToXHTML( 20 | "o/", 21 | ), 22 | ).to.equal( 23 | "o/", 24 | ); 25 | }); 26 | it("should transform a message with a link", () => { 27 | expect( 28 | XHTMLIM.HTMLToXHTML( 29 | "bob: Huzzah!", 30 | ), 31 | ).to.equal( 32 | "" 33 | + "bob: Huzzah!", 34 | ); 35 | }); 36 | it("should transform a message with a reply", () => { 37 | expect( 38 | XHTMLIM.HTMLToXHTML( 39 | "
In reply to" 41 | + "" 42 | + "@Half-Shot:half-shot.uk
This is the first message
" 43 | + "And this is a reply", 44 | ), 45 | ).to.equal( 46 | "
In reply to@Half-Shot:half-shot.uk
This is the first message
And" 50 | + " this is a reply", 51 | ); 52 | }); 53 | // 54 | it("should transform an inline image", () => { 55 | const xhtmlValue = XHTMLIM.HTMLToXHTML("Here is a pretty image\"shadow\""); 56 | assertXML(xhtmlValue); 57 | expect(xhtmlValue).to.equal("Here is a pretty image" + 58 | "shadow"); 59 | }) 60 | }); 61 | -------------------------------------------------------------------------------- /test/xmppjs/test_XJSAccount.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as Chai from "chai"; 3 | import { XmppJsAccount } from "../../src/xmppjs/XJSAccount"; 4 | import { IBasicProtocolMessage } from "../../src/MessageFormatter"; 5 | import { MockXJSInstance } from "../mocks/XJSInstance"; 6 | 7 | const expect = Chai.expect; 8 | 9 | let acct: XmppJsAccount; 10 | const instance = new MockXJSInstance(); 11 | instance.accountUsername = "bob@matrix.localhost"; 12 | 13 | function createXJSAccount() { 14 | return new XmppJsAccount( 15 | "bob@matrix.localhost", 16 | "matrix-bridge", 17 | instance as any, 18 | "@bob:localhost", 19 | ); 20 | } 21 | 22 | describe("XJSAccount", () => { 23 | 24 | beforeEach(() => { 25 | acct = createXJSAccount(); 26 | }); 27 | 28 | it("should have the correct property values on construction", () => { 29 | expect(acct.connected).to.be.true; 30 | expect(acct.remoteId).to.be.equal("bob@matrix.localhost"); 31 | expect(acct.roomHandles).to.be.empty; 32 | }); 33 | 34 | describe("sendIM", () => { 35 | it("should be able to send a basic message", () => { 36 | acct.sendIM("alice@remote.server", { 37 | body: "Hello!", 38 | id: "12345", 39 | } as IBasicProtocolMessage); 40 | expect(instance.sentMessageIDs).to.include("12345"); 41 | expect(instance.sentMessages[0]).to.deep.equal({ 42 | chatstate: undefined, 43 | replacesId: undefined, 44 | hFrom: "bob@matrix.localhost/matrix-bridge", 45 | hTo: "alice@remote.server", 46 | messageType: "chat", 47 | hId: "12345", 48 | html: "", 49 | body: "Hello!", 50 | markable: true, 51 | attachments: [], 52 | }); 53 | }); 54 | }); 55 | 56 | describe("joinChat", () => { 57 | it("should be able to join a chat", async () => { 58 | await acct.joinChat({ 59 | room: "den", 60 | server: "remote.server", 61 | handle: "Bob", 62 | }, instance as any, 50, true); 63 | }); 64 | 65 | it("should fail to join a chat without the required components", async () => { 66 | try { 67 | await acct.joinChat({ 68 | room: "den", 69 | server: "remote.server", 70 | // Explicit any - we want to deliberately send wrong params 71 | } as any, instance as any, 50, true); 72 | } catch (ex) { 73 | expect(ex.message).to.equal("Missing handle"); 74 | return; 75 | } 76 | throw Error("Didn't throw"); 77 | }); 78 | 79 | }); 80 | 81 | afterEach(() => { 82 | acct.stop(); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /test/xmppjs/test_XJSInstance.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as Chai from "chai"; 3 | import { Config } from "../../src/Config"; 4 | import { XmppJsInstance, XMPP_PROTOCOL } from "../../src/xmppjs/XJSInstance"; 5 | 6 | const expect = Chai.expect; 7 | 8 | describe("XJSInstance", () => { 9 | let config: Config; 10 | before(() => { 11 | config = new Config(); 12 | config.ApplyConfig({ 13 | purple: { 14 | backendOpts: {} 15 | } 16 | }); 17 | }) 18 | it("should match an xmpp username", () => { 19 | const instance = new XmppJsInstance(config, {} as any); 20 | const res = instance.getUsernameFromMxid("@_xmpp_frogman=40frogplanet.com:example.com", "_xmpp_"); 21 | expect(res.protocol).to.equal(XMPP_PROTOCOL); 22 | expect(res.username).to.equal("frogman@frogplanet.com"); 23 | }); 24 | 25 | it("should match an xmpp username with a resource", () => { 26 | const instance = new XmppJsInstance(config, {} as any); 27 | const res = instance.getUsernameFromMxid("@_xmpp_frogdevice=2ffrogman=40frogplanet.com:example.com", "_xmpp_"); 28 | expect(res.protocol).to.equal(XMPP_PROTOCOL); 29 | expect(res.username).to.equal("frogman@frogplanet.com/frogdevice"); 30 | }); 31 | 32 | it("should be able to transform a xmpp username to a mxid and back", () => { 33 | const username = "frogman@frogplanet.com/frog$£!%& device"; 34 | const instance = new XmppJsInstance(config, {} as any); 35 | const mxUser = XMPP_PROTOCOL.getMxIdForProtocol( 36 | username, "example.com", "_xmpp_", 37 | ).userId; 38 | const res = instance.getUsernameFromMxid( 39 | mxUser, "_xmpp_", 40 | ); 41 | expect(res.protocol).to.equal(XMPP_PROTOCOL); 42 | expect(res.username).to.equal(username); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/xmppjs/test_presencecache.ts: -------------------------------------------------------------------------------- 1 | import * as Chai from "chai"; 2 | import { PresenceCache } from "../../src/xmppjs/PresenceCache"; 3 | import { x } from "@xmpp/xml"; 4 | const expect = Chai.expect; 5 | 6 | const aliceJoin = x("presence", { 7 | xmlns: "jabber:client", 8 | to: "bob@xmpp.matrix.org/fakedevice", 9 | from: "aroom@conf.xmpp.matrix.org/alice", 10 | }); 11 | 12 | const aliceJoinGateway = x("presence", { 13 | xmlns: "jabber:client", 14 | from: "alice@xmpp.matrix.org/fakedevice", 15 | to: "aroom@conf.xmpp.matrix.org/alice", 16 | }); 17 | 18 | const aliceLeave = x("presence", { 19 | to: "bob@xmpp.matrix.org/fakedevice", 20 | from: "aroom@conf.xmpp.matrix.org/alice", 21 | type: "unavailable", 22 | }, [ 23 | x("x", {xmlns: "http://jabber.org/protocol/muc#user"}, [ 24 | x("item", {affiliation: "none", role: "none"}), 25 | ]), 26 | ]); 27 | 28 | const bobJoin = x("presence", { 29 | to: "bob@xmpp.matrix.org/fakedevice", 30 | from: "aroom@conf.xmpp.matrix.org/bob", 31 | }, [ 32 | x("x", {xmlns: "http://jabber.org/protocol/muc#user"}, [ 33 | x("item", {affiliation: "member", role: "participant"}), 34 | x("status", {code: "110"}), 35 | ]), 36 | ]); 37 | 38 | const aliceSeesBobJoin = x("presence", { 39 | to: "alice@xmpp.matrix.org/fakedevice", 40 | from: "aroom@conf.xmpp.matrix.org/bob", 41 | }, [ 42 | x("x", {xmlns: "http://jabber.org/protocol/muc#user"}, [ 43 | x("item", {affiliation: "member", role: "participant"}), 44 | ]), 45 | ]); 46 | 47 | const bobLeave = x("presence", { 48 | to: "bob@xmpp.matrix.org/fakedevice", 49 | from: "aroom@conf.xmpp.matrix.org/bob", 50 | type: "unavailable", 51 | }, [ 52 | x("x", {xmlns: "http://jabber.org/protocol/muc#user"}, [ 53 | x("item", {affiliation: "none", role: "none"}), 54 | x("status", {code: "110"}), 55 | ]), 56 | ]); 57 | 58 | const aliceKick = x("presence", { 59 | xmlns: "jabber:client", 60 | to: "bob@xmpp.matrix.org/fakedevice", 61 | from: "aroom@conf.xmpp.matrix.org/alice", 62 | type: "unavailable", 63 | }, 64 | x("x", { 65 | xmlns: "http://jabber.org/protocol/muc#user", 66 | }, 67 | [ 68 | x("status", { 69 | code: "307", 70 | }), 71 | x("item", undefined, [ 72 | x("actor", { 73 | nick: "bob", 74 | }), 75 | x("reason", undefined, "Didn't like em much"), 76 | ]), 77 | ], 78 | )); 79 | 80 | describe("PresenceCache", () => { 81 | it("should parse a join message", () => { 82 | const p = new PresenceCache(); 83 | const delta = p.add(aliceJoin)!; 84 | expect(delta).to.not.be.undefined; 85 | expect(delta.changed).to.contain("online"); 86 | expect(delta.changed).to.contain("new"); 87 | expect(delta.error).to.be.null; 88 | expect(delta.isSelf).to.be.false; 89 | expect(delta.status!.resource).to.eq("alice"); 90 | const status = p.getStatus("aroom@conf.xmpp.matrix.org/alice"); 91 | expect(status).to.not.be.undefined; 92 | expect(status!.online).to.be.true; 93 | expect(status!.ours).to.be.false; 94 | expect(status!.resource).to.eq("alice"); 95 | }); 96 | 97 | it("should parse a leave message", () => { 98 | const p = new PresenceCache(); 99 | p.add(aliceJoin)!; 100 | const delta = p.add(aliceLeave)!; 101 | expect(delta).to.not.be.undefined; 102 | expect(delta.changed).to.contain("offline"); 103 | expect(delta.error).to.be.null; 104 | expect(delta.isSelf).to.be.false; 105 | expect(delta.status!.resource).to.eq("alice"); 106 | const status = p.getStatus("aroom@conf.xmpp.matrix.org/alice"); 107 | expect(status).to.not.be.undefined; 108 | expect(status!.online).to.be.false; 109 | expect(status!.ours).to.be.false; 110 | expect(status!.resource).to.eq("alice"); 111 | }); 112 | 113 | it("should parse own join and leave", () => { 114 | const p = new PresenceCache(); 115 | let delta; 116 | delta = p.add(bobJoin)!; 117 | expect(delta).to.not.be.undefined; 118 | expect(delta.changed).to.contain("online"); 119 | expect(delta.changed).to.contain("new"); 120 | expect(delta.error).to.be.null; 121 | expect(delta.isSelf).to.be.true; 122 | expect(delta.status!.resource).to.eq("bob"); 123 | delta = p.add(bobLeave)!; 124 | expect(delta).to.not.be.undefined; 125 | expect(delta.changed).to.contain("offline"); 126 | expect(delta.error).to.be.null; 127 | expect(delta.isSelf).to.be.true; 128 | expect(delta.status!.resource).to.eq("bob"); 129 | const status = p.getStatus("aroom@conf.xmpp.matrix.org/bob"); 130 | expect(status).to.not.be.undefined; 131 | expect(status!.online).to.be.false; 132 | expect(status!.ours).to.be.true; 133 | expect(status!.resource).to.eq("bob"); 134 | }); 135 | 136 | it("should handle join presence races", () => { 137 | const p = new PresenceCache(); 138 | let delta; 139 | delta = p.add(aliceSeesBobJoin)!; 140 | expect(delta).to.not.be.undefined; 141 | expect(delta.changed).to.contain("online"); 142 | expect(delta.changed).to.contain("new"); 143 | expect(delta.error).to.be.null; 144 | expect(delta.isSelf).to.be.false; 145 | expect(delta.status!.resource).to.eq("bob"); 146 | delta = p.add(bobJoin)!; 147 | expect(delta).to.not.be.undefined; 148 | expect(delta.changed).to.contain("online"); 149 | expect(delta.error).to.be.null; 150 | expect(delta.isSelf).to.be.true; 151 | expect(delta.status!.resource).to.eq("bob"); 152 | const status = p.getStatus("aroom@conf.xmpp.matrix.org/bob"); 153 | expect(status).to.not.be.undefined; 154 | expect(status!.online).to.be.true; 155 | expect(status!.ours).to.be.true; 156 | expect(status!.resource).to.eq("bob"); 157 | }); 158 | 159 | it("should parse a kick message", () => { 160 | const p = new PresenceCache(); 161 | p.add(aliceJoin)!; 162 | const delta = p.add(aliceKick)!; 163 | expect(delta).to.not.be.undefined; 164 | expect(delta.changed).to.contain("kick"); 165 | expect(delta.error).to.be.null; 166 | expect(delta.isSelf).to.be.false; 167 | expect(delta.status!.resource).to.eq("alice"); 168 | const status = p.getStatus("aroom@conf.xmpp.matrix.org/alice"); 169 | expect(status).to.not.be.undefined; 170 | expect(status!.online).to.be.false; 171 | expect(status!.ours).to.be.false; 172 | expect(status!.kick!.kicker).to.eq("bob"); 173 | expect(status!.kick!.reason).to.eq("Didn't like em much"); 174 | expect(status!.resource).to.eq("alice"); 175 | }); 176 | 177 | it("should handle two new devices in gateway mode", () => { 178 | const p = new PresenceCache(true); 179 | p.add(aliceJoinGateway)!; 180 | const delta2 = p.add(x("presence", { 181 | xmlns: "jabber:client", 182 | from: "alice@xmpp.matrix.org/fakedevice2", 183 | to: "aroom@conf.xmpp.matrix.org/alice", 184 | }))!; 185 | expect(delta2).to.not.be.undefined; 186 | expect(delta2.changed).to.not.contain("online"); 187 | expect(delta2.changed).to.not.contain("new"); 188 | expect(delta2.changed).to.contain("newdevice"); 189 | expect(delta2.error).to.be.null; 190 | expect(delta2.isSelf).to.be.false; 191 | const status = p.getStatus("aroom@conf.xmpp.matrix.org/alice"); 192 | expect(status).to.not.be.undefined; 193 | expect(status!.online).to.be.true; 194 | expect(status!.ours).to.be.false; 195 | expect(status!.resource).to.eq("alice"); 196 | expect(status!.devices!).to.contain("fakedevice"); 197 | expect(status!.devices!).to.contain("fakedevice2"); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /test/xmppjs/util.ts: -------------------------------------------------------------------------------- 1 | import { XMLValidator } from "fast-xml-parser"; 2 | import { AssertionError } from "chai"; 3 | 4 | export function assertXML(xml) { 5 | const err = XMLValidator.validate(xml); 6 | if (err !== true) { 7 | throw new AssertionError(err.err.code + ": " + err.err.msg); 8 | } 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20", 3 | "compilerOptions": { 4 | "incremental": true, 5 | "allowJs": false, 6 | "declaration": false, 7 | "sourceMap": true, 8 | "outDir": "./lib", 9 | "composite": false, 10 | // TODO: Enable these 11 | "strict": false, 12 | "strictNullChecks": false 13 | }, 14 | "include": [ 15 | "src/**/*" 16 | ], 17 | } 18 | --------------------------------------------------------------------------------