├── .github └── dependabot.yml ├── .gitignore ├── .prettierrc ├── .yarnignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── 000.1-solana-monitoring-service.ts ├── 000.2-solana-monitoring-service-client.ts ├── 000.3-aptos-monitoring-service.ts ├── 001-data-source-monitor.ts ├── 002-subscribers-monitor.ts ├── 003-custom-subscriber-repository.ts ├── 004-custom-notification-sink.ts ├── 005-custom-pipelines-using-operators.ts ├── 006-increase-trigger.ts ├── 007-pushy-data-source-monitor.ts ├── 008-diff-pipeline.ts ├── 009-change-pipeline.ts ├── 010-email-notification-monitor.ts ├── 011-sms-notification-monitor.ts ├── 011-telegram-notification-monitor.ts ├── 012-all-web2-notification-monitor.ts ├── 013-solflare-notification-monitor.ts └── 014-dialect-sdk-notification-monitor.ts ├── jest.config.ts ├── package.json ├── src ├── data-model.ts ├── dialect-sdk-notification-sink.ts ├── dialect-sdk-subscriber.repository.ts ├── dialect-thread-notification-sink.ts ├── index.ts ├── internal │ ├── default-monitor-factory.ts │ ├── default-monitor.ts │ ├── in-memory-subscriber.repository.ts │ ├── monitor-builder.ts │ ├── notification-type-eligibility-predicate.ts │ └── subscriber-repository-factory.ts ├── monitor-api.ts ├── monitor-builder.ts ├── monitor-factory.ts ├── ports.ts ├── sengrid-email-notification-sink.ts ├── solflare-notification-sink.ts ├── telegram-notification-sink.ts ├── transformation-pipeline-operators.ts ├── transformation-pipelines.ts └── twilio-sms-notification-sink.ts ├── tsconfig.json └── yarn.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | versioning-strategy: increase 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | labels: 9 | - "dependencies" 10 | open-pull-requests-limit: 100 11 | pull-request-branch-name: 12 | separator: "-" 13 | allow: 14 | - dependency-name: "@dialectlabs*" 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | **/node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | /node_modules/ 37 | /lib/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.yarnignore: -------------------------------------------------------------------------------- 1 | src -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## [UNRELEASED] 4 | 5 | ## [3.5.1] - 2024-04-26 6 | 7 | - libs: bump dialect sdk version 8 | 9 | ## [3.5.0] - 2024-04-24 10 | 11 | - feat: support actions in sdk notifications 12 | - chore: bump deps 13 | 14 | ## [3.4.2] - 2022-12-23 15 | 16 | - chore: add available notification type ids to notification type id resolving error 17 | 18 | ## [3.4.1] - 2022-12-23 19 | 20 | - chore: shorten error message in dialect-sdk notification sink on notification type mismatch 21 | 22 | ## [3.4.0] - 2022-12-09 23 | 24 | - Bump SDK to 1.4.0, blockchain-sdk-aptos 1.0.3, blockchain-sdk-solana 1.0.1 25 | 26 | ## [3.3.3] - 2022-09-11 27 | 28 | - fix: skip sending notifications for empty recipient sets in dialect sdk sink 29 | 30 | ## [3.3.2] - 2022-08-08 31 | 32 | - chore: shorten subscribers logging. 33 | 34 | ## [3.3.1] - 2022-08-07 35 | 36 | - Bump sdk dependency to 0.5.1. 37 | 38 | ## [3.3.0] - 2022-07-27 39 | 40 | - Add configurable notification types. 41 | 42 | ## [3.1.0] - 2022-07-06 43 | 44 | - Add pipeline for tracking change of arbitrary value based on user-provided comparator. 45 | 46 | ## [3.0.3] - 2022-06-22 47 | 48 | - Increase poll timeout threshold for larger poll intervals. 49 | 50 | ## [3.0.2] - 2022-06-22 51 | 52 | - Bump sdk dependency to 0.2.0. 53 | 54 | ## [3.0.1] - 2022-06-22 55 | 56 | - Bump sdk dependency to 0.1.4. 57 | 58 | ## [3.0.0] - 2022-06-22 59 | 60 | - Migrate to Dialect SDK for all underlying API calls and monitor initialization. 61 | - Add monitor client that can be used to emulate subscribers and auto-register dapp in Dialect. 62 | 63 | ## [2.3.0] - 2022-06-16 64 | 65 | - Add Solflare notification sink. 66 | 67 | ## [2.2.0] - 2022-05-26 68 | 69 | - Switch to Bearer authN based on dApp signed token for requests targeting dialect web2 services. 70 | 71 | ## [2.1.0] - 2022-05-19 72 | 73 | - Support setting a limit for which rising and falling edge triggers stop firing event. 74 | 75 | ## [2.0.8] - 2022-05-18 76 | 77 | - Start collecting changelog. 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Enombic Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Monitoring Toolkit 2 | 3 | Monitor is an open-source framework that makes it easy to extract and transform on-chain data into targeted, timely smart messages. You can implement a monitor service to provide your dApp's users with smart messages. 4 | 5 | The monitor framework provides features to seamlessly integrate notifications with your dApp: 6 | 1. Ability to track dApp users that subscribe to notifications 7 | 2. Ability to continuously monitor on-chain resources, like accounts, for a set of your subscribers 8 | 3. A rich, high-level API for unbounded data-stream processing to analyze the extracted on-chain data 9 | 10 | Data-stream processing features include: 11 | - Windowing: fixed size, fixed time, fixed size sliding 12 | - Aggregation: average, min, max 13 | - Thresholding: rising edge, falling edge 14 | - Rate limiting 15 | 16 | ## Installation 17 | 18 | **npm:** 19 | 20 | ```shell 21 | npm install @dialectlabs/monitor 22 | ``` 23 | 24 | **yarn:** 25 | 26 | ```shell 27 | yarn add @dialectlabs/monitor 28 | ``` 29 | 30 | ## Usage 31 | 32 | Dialect's monitor is best learned by example. This section describes how to use Dialect monitor to build a monitoring apps by showing you various example apps in the `examples/` folder of this repository. Follow along in this section, & refer to the code in those examples. 33 | 34 | Examples start from real application that is utilizing solana blockchain and dialect program, then we provide some examples 35 | that don't utilize solana as a dependency to run for development simplicity. 36 | 37 | ### examples/000.1-solana-monitoring-service.ts 38 | 39 | This example emulates e2e scenario for monitoring some on chain resources for a set of subscribers and has 2 parts: 40 | 41 | 1) Client that emulates several users subscribing for dialect notifications from a monitoring service 42 | 2) Server that monitors some data for a set of monitoring service subscribers 43 | 44 | Server example: 45 | 46 | ```typescript 47 | import { Monitor, Monitors, Pipelines, ResourceId, SourceData } from '../src'; 48 | import { Duration } from 'luxon'; 49 | 50 | // 1. Common Dialect SDK imports 51 | import { 52 | Dialect, 53 | DialectCloudEnvironment, 54 | DialectSdk, 55 | } from '@dialectlabs/sdk'; 56 | 57 | // 2. Solana-specific imports 58 | import { 59 | Solana, 60 | SolanaSdkFactory, 61 | NodeDialectSolanaWalletAdapter 62 | } from '@dialectlabs/blockchain-sdk-solana'; 63 | 64 | // 3. Create Dialect Solana SDK 65 | const environment: DialectCloudEnvironment = 'development'; 66 | const dialectSolanaSdk: DialectSdk = Dialect.sdk( 67 | { 68 | environment, 69 | }, 70 | SolanaSdkFactory.create({ 71 | // IMPORTANT: must set environment variable DIALECT_SDK_CREDENTIALS 72 | // to your dapp's Solana messaging wallet keypair e.g. [170,23, . . . ,300] 73 | wallet: NodeDialectSolanaWalletAdapter.create(), 74 | }), 75 | ); 76 | 77 | // 4. Define a data type to monitor 78 | type YourDataType = { 79 | cratio: number; 80 | healthRatio: number; 81 | resourceId: ResourceId; 82 | }; 83 | 84 | // 5. Build a Monitor to detect and deliver notifications 85 | const dataSourceMonitor: Monitor = Monitors.builder({ 86 | sdk: dialectSolanaSdk, 87 | subscribersCacheTTL: Duration.fromObject({ seconds: 5 }), 88 | }) 89 | // (5a) Define data source type 90 | .defineDataSource() 91 | // (5b) Supply data to monitor, in this case by polling 92 | // Push type available, see example 007 93 | .poll((subscribers: ResourceId[]) => { 94 | const sourceData: SourceData[] = subscribers.map( 95 | (resourceId) => ({ 96 | data: { 97 | cratio: Math.random(), 98 | healthRatio: Math.random(), 99 | resourceId, 100 | }, 101 | groupingKey: resourceId.toString(), 102 | }), 103 | ); 104 | return Promise.resolve(sourceData); 105 | }, Duration.fromObject({ seconds: 3 })) 106 | // (5c) Transform data source to detect events 107 | .transform({ 108 | keys: ['cratio'], 109 | pipelines: [ 110 | Pipelines.threshold({ 111 | type: 'falling-edge', 112 | threshold: 0.5, 113 | }), 114 | ], 115 | }) 116 | // (5d) Add notification sinks for message delivery strategy 117 | // Monitor has several sink types available out-of-the-box. 118 | // You can also define your own sinks to deliver over custom channels 119 | // (see examples/004-custom-notification-sink) 120 | // Below, the dialectSdk sync is used, which handles logic to send 121 | // notifications to all all (and only) the channels which has subscribers have enabled 122 | .notify() 123 | .dialectSdk( 124 | ({ value }) => { 125 | return { 126 | title: 'dApp cratio warning', 127 | message: `Your cratio = ${value} below warning threshold`, 128 | }; 129 | }, 130 | { 131 | dispatch: 'unicast', 132 | to: ({ origin: { resourceId } }) => resourceId, 133 | }, 134 | ) 135 | .also() 136 | .transform({ 137 | keys: ['cratio'], 138 | pipelines: [ 139 | Pipelines.threshold({ 140 | type: 'rising-edge', 141 | threshold: 0.5, 142 | }), 143 | ], 144 | }) 145 | .notify() 146 | .dialectSdk( 147 | ({ value }) => { 148 | return { 149 | title: 'dApp cratio warning', 150 | message: `Your cratio = ${value} above warning threshold`, 151 | }; 152 | }, 153 | { 154 | dispatch: 'unicast', 155 | to: ({ origin: { resourceId } }) => resourceId, 156 | }, 157 | ) 158 | // (5e) Build the Monitor 159 | .and() 160 | .build(); 161 | 162 | // 6. Start the monitor 163 | dataSourceMonitor.start(); 164 | 165 | ``` 166 | 167 | Please follow the instructions below to run the example 168 | 169 | #### Step 1. Generate a new test keypair representing your dapp's monitoring service 170 | 171 | ```bash 172 | export your_path=~/projects/dialect/keypairs/ 173 | solana-keygen new --outfile ${your_path}/monitor-localnet-keypair.private 174 | solana-keygen pubkey ${your_path}/monitor-localnet-keypair.private > ${your_path}/monitor-localnet-keypair.public 175 | ``` 176 | 177 | #### Step 2. Start server with keypair assigned to env DIALECT_SDK_CREDENTIALS 178 | 179 | ```bash 180 | cd examples 181 | export your_path=~/projects/dialect/keypairs 182 | DIALECT_SDK_CREDENTIALS=$(cat ${your_path}/monitor-localnet-keypair.private) ts-node ./000.1-solana-monitoring-service-server.ts 183 | ``` 184 | 185 | #### Step 3. Start client 186 | 187 | ```bash 188 | cd examples 189 | export your_path=~/projects/dialect/keypairs 190 | DAPP_PUBLIC_KEY=$(cat ${your_path}/monitor-localnet-keypair.public) \ 191 | ts-node ./000.1-solana-monitoring-service-client.ts 192 | ``` 193 | 194 | #### Step 4. Look at client logs for notifications 195 | 196 | When both client and server are started, server will start polling data and notifying subscribers 197 | 198 | ### 001-data-source-monitor 199 | 200 | Shows an example of how to define custom data source, transform data and generate notifications Doesn't exchange data 201 | with on-chain program for development simplicity. 202 | 203 | #### Start this example 204 | 205 | ```bash 206 | cd examples 207 | ts-node ./001-data-source-monitor.ts 208 | ``` 209 | 210 | ### 002-subscribers-monitor 211 | 212 | Shows an example of how subscribe to events about subscriber state changes and generate notifications. Useful e.g. for 213 | sending new subscriber greetings or cleaning some data when subscriber removed. Doesn't exchange data with on-chain 214 | program for development simplicity. 215 | 216 | ### Start this example 217 | 218 | ```bash 219 | cd examples 220 | ts-node ./002-subscribers-monitor.ts 221 | ``` 222 | 223 | ### 003-custom-subscriber-repository 224 | 225 | Shows an example of how to define custom dummy subscriber repository instead of getting this data from DialectCloud. Useful for local development. 226 | 227 | ### 004-custom-notification-sink 228 | 229 | Shows an example of how to write a custom notification sink. Console log example useful for local development. 230 | 231 | ### 005-custom-pipelines-using-operators 232 | 233 | Shows an example of how to develop an analytical pipeline using a set of subsequent more low-level transformation 234 | operators. 235 | 236 | If you're interested in developing on Dialect while making live changes to the library, see the Development section below. 237 | 238 | ## Development 239 | 240 | ### Prerequisites 241 | 242 | - Git 243 | - Yarn (<2) 244 | - Nodejs (>=15.10 <17) 245 | 246 | ### Getting started with monitor development in this repo 247 | 248 | #### Install dependencies 249 | 250 | **npm:** 251 | 252 | ```shell 253 | npm install 254 | ``` 255 | 256 | **yarn:** 257 | 258 | ```shell 259 | yarn 260 | ``` 261 | 262 | #### Take a look at examples and corresponding readme file 263 | 264 | After getting familiar with https://github.com/dialectlabs/monitor/blob/main/examples/README.md and examples you'll be ready to implement a new monitoring service. 265 | -------------------------------------------------------------------------------- /examples/000.1-solana-monitoring-service.ts: -------------------------------------------------------------------------------- 1 | import { Monitor, Monitors, Pipelines, ResourceId, SourceData } from '../src'; 2 | import { Duration } from 'luxon'; 3 | 4 | // 1. Common Dialect SDK imports 5 | import { 6 | Dialect, 7 | DialectCloudEnvironment, 8 | DialectSdk, 9 | } from '@dialectlabs/sdk'; 10 | 11 | // 2. Solana-specific imports 12 | import { 13 | Solana, 14 | SolanaSdkFactory, 15 | NodeDialectSolanaWalletAdapter 16 | } from '@dialectlabs/blockchain-sdk-solana'; 17 | 18 | // 3. Create Dialect Solana SDK 19 | const environment: DialectCloudEnvironment = 'development'; 20 | const dialectSolanaSdk: DialectSdk = Dialect.sdk( 21 | { 22 | environment, 23 | }, 24 | SolanaSdkFactory.create({ 25 | // IMPORTANT: must set environment variable DIALECT_SDK_CREDENTIALS 26 | // to your dapp's Solana messaging wallet keypair e.g. [170,23, . . . ,300] 27 | wallet: NodeDialectSolanaWalletAdapter.create(), 28 | }), 29 | ); 30 | 31 | // 4. Define a data type to monitor 32 | type YourDataType = { 33 | cratio: number; 34 | healthRatio: number; 35 | resourceId: ResourceId; 36 | }; 37 | 38 | // 5. Build a Monitor to detect and deliver notifications 39 | const dataSourceMonitor: Monitor = Monitors.builder({ 40 | sdk: dialectSolanaSdk, 41 | subscribersCacheTTL: Duration.fromObject({ seconds: 5 }), 42 | }) 43 | // (5a) Define data source type 44 | .defineDataSource() 45 | // (5b) Supply data to monitor, in this case by polling 46 | // Do on- or off-chain data extraction here to monitor changing datasets 47 | // .push also available, see example/007-pushy-data-source-monitor.ts 48 | .poll((subscribers: ResourceId[]) => { 49 | const sourceData: SourceData[] = subscribers.map( 50 | (resourceId) => ({ 51 | data: { 52 | cratio: Math.random(), 53 | healthRatio: Math.random(), 54 | resourceId, 55 | }, 56 | groupingKey: resourceId.toString(), 57 | }), 58 | ); 59 | return Promise.resolve(sourceData); 60 | }, Duration.fromObject({ seconds: 3 })) 61 | // (5c) Transform data source to detect events 62 | .transform({ 63 | keys: ['cratio'], 64 | pipelines: [ 65 | Pipelines.threshold({ 66 | type: 'falling-edge', 67 | threshold: 0.5, 68 | }), 69 | ], 70 | }) 71 | // (5d) Add notification sinks for message delivery strategy 72 | // Monitor has several sink types available out-of-the-box. 73 | // You can also define your own sinks to deliver over custom channels 74 | // (see examples/004-custom-notification-sink) 75 | // Below, the dialectSdk sync is used, which handles logic to send 76 | // notifications to all all (and only) the channels which has subscribers have enabled 77 | .notify() 78 | .dialectSdk( 79 | ({ value }) => { 80 | return { 81 | title: 'dApp cratio warning', 82 | message: `Your cratio = ${value} below warning threshold`, 83 | }; 84 | }, 85 | { 86 | dispatch: 'unicast', 87 | to: ({ origin: { resourceId } }) => resourceId, 88 | }, 89 | ) 90 | .also() 91 | .transform({ 92 | keys: ['cratio'], 93 | pipelines: [ 94 | Pipelines.threshold({ 95 | type: 'rising-edge', 96 | threshold: 0.5, 97 | }), 98 | ], 99 | }) 100 | .notify() 101 | .dialectSdk( 102 | ({ value }) => { 103 | return { 104 | title: 'dApp cratio warning', 105 | message: `Your cratio = ${value} above warning threshold`, 106 | }; 107 | }, 108 | { 109 | dispatch: 'unicast', 110 | to: ({ origin: { resourceId } }) => resourceId, 111 | }, 112 | ) 113 | // (5e) Build the Monitor 114 | .and() 115 | .build(); 116 | 117 | // 6. Start the monitor 118 | dataSourceMonitor.start(); 119 | -------------------------------------------------------------------------------- /examples/000.2-solana-monitoring-service-client.ts: -------------------------------------------------------------------------------- 1 | import { Keypair, PublicKey } from '@solana/web3.js'; 2 | 3 | import { 4 | AddressType, 5 | ConfigProps, 6 | Dialect, 7 | DialectSdk, 8 | ThreadMemberScope, 9 | } from '@dialectlabs/sdk'; 10 | 11 | import { 12 | Solana, 13 | SolanaSdkFactory, 14 | NodeDialectSolanaWalletAdapter 15 | } from '@dialectlabs/blockchain-sdk-solana'; 16 | 17 | const main = async (): Promise => { 18 | const dappPublicKeyFromEnv = process.env.DAPP_PUBLIC_KEY; 19 | if (!dappPublicKeyFromEnv) { 20 | return; 21 | } 22 | await startClients(dappPublicKeyFromEnv); 23 | }; 24 | 25 | async function startClients(dappPublicKeyFromEnv: string) { 26 | const dappPublicKey = new PublicKey(dappPublicKeyFromEnv); 27 | if (dappPublicKeyFromEnv) { 28 | await Promise.all([ 29 | createClient( 30 | { 31 | environment: 'development', 32 | }, 33 | dappPublicKey, 34 | ), 35 | createClient( 36 | { 37 | environment: 'development', 38 | }, 39 | dappPublicKey, 40 | ), 41 | ]); 42 | } 43 | } 44 | 45 | export async function createClient( 46 | config: Omit, 47 | dappPublicKey: PublicKey, 48 | ): Promise { 49 | const userKeypair = Keypair.generate(); 50 | const userWalletAdapter = NodeDialectSolanaWalletAdapter.create(userKeypair); 51 | 52 | //const environment: DialectCloudEnvironment = 'development'; 53 | const dialectSolanaSdk: DialectSdk = Dialect.sdk( 54 | config, 55 | SolanaSdkFactory.create({ 56 | // IMPORTANT: must set environment variable DIALECT_SDK_CREDENTIALS 57 | // to your dapp's Solana messaging wallet keypair e.g. [170,23, . . . ,300] 58 | wallet: userWalletAdapter, 59 | }), 60 | ); 61 | 62 | const address = await dialectSolanaSdk.wallet.addresses.create({ 63 | type: AddressType.Wallet, 64 | value: userWalletAdapter.publicKey.toBase58(), 65 | }); 66 | await dialectSolanaSdk.wallet.dappAddresses.create({ 67 | addressId: address.id, 68 | dappAccountAddress: dappPublicKey.toBase58(), 69 | enabled: true, 70 | }); 71 | console.log( 72 | `Created address for wallet: ${userWalletAdapter.publicKey.toBase58()} and linked with dApp ${dappPublicKey}`, 73 | ); 74 | const thread = await dialectSolanaSdk.threads.create({ 75 | me: { scopes: [ThreadMemberScope.WRITE, ThreadMemberScope.ADMIN] }, 76 | otherMembers: [ 77 | { 78 | address: dappPublicKey.toBase58(), 79 | scopes: [ThreadMemberScope.WRITE], 80 | }, 81 | ], 82 | encrypted: false, 83 | }); 84 | console.log( 85 | `Created ${ 86 | thread.type 87 | } thread for members: [${userWalletAdapter.publicKey.toBase58()}, ${dappPublicKey.toBase58()}]`, 88 | ); 89 | let isInterrupted = false; 90 | process.on('SIGINT', async () => { 91 | console.log('Cleaning created resources'); 92 | isInterrupted = true; 93 | console.log( 94 | `Deleting ${ 95 | thread.type 96 | } thread for members: [${userWalletAdapter.publicKey.toBase58()}, ${dappPublicKey.toBase58()}]`, 97 | ); 98 | await thread.delete(); 99 | console.log(`Deleting address for ${userWalletAdapter.publicKey.toBase58()}`); 100 | await dialectSolanaSdk.wallet.addresses.delete({ addressId: address.id }); 101 | console.log('Please wait'); 102 | // process.exit(0); 103 | }); 104 | let lastMessageTimestamp = thread.updatedAt; 105 | while (!isInterrupted) { 106 | const messages = await thread.messages(); 107 | const newMessages = messages.filter( 108 | (it) => it.timestamp.getTime() > lastMessageTimestamp.getTime(), 109 | ); 110 | if (newMessages.length > 0) { 111 | console.log( 112 | `Got ${newMessages.length} new messages for ${ 113 | thread.type 114 | } thread [${userWalletAdapter.publicKey.toBase58()}, ${dappPublicKey.toBase58()}]: 115 | ${newMessages.map((it) => it.text)}`, 116 | ); 117 | } 118 | lastMessageTimestamp = thread.updatedAt; 119 | await sleep(1000); 120 | } 121 | } 122 | 123 | function sleep( 124 | ms: number, 125 | ): Promise<(value: (() => void) | PromiseLike<() => void>) => void> { 126 | return new Promise((resolve) => setTimeout(resolve, ms)); 127 | } 128 | 129 | main(); 130 | -------------------------------------------------------------------------------- /examples/000.3-aptos-monitoring-service.ts: -------------------------------------------------------------------------------- 1 | import { Monitor, Monitors, Pipelines, ResourceId, SourceData } from '../src'; 2 | import { Duration } from 'luxon'; 3 | 4 | // 1. Common Dialect SDK imports 5 | import { 6 | Dialect, 7 | DialectCloudEnvironment, 8 | DialectSdk, 9 | } from '@dialectlabs/sdk'; 10 | 11 | // 2. Aptos-specific imports 12 | import { 13 | Aptos, 14 | AptosSdkFactory, 15 | NodeDialectAptosWalletAdapter 16 | } from '@dialectlabs/blockchain-sdk-aptos'; 17 | 18 | 19 | // 3. Create Dialect Aptos SDK 20 | const environment: DialectCloudEnvironment = 'development'; 21 | const dialectAptosSdk: DialectSdk = Dialect.sdk( 22 | { 23 | environment, 24 | }, 25 | AptosSdkFactory.create({ 26 | // IMPORTANT: must set environment variable DIALECT_SDK_CREDENTIALS 27 | // to your dapp's Aptos messaging wallet keypair e.g. [170,23, . . . ,300] 28 | wallet: NodeDialectAptosWalletAdapter.create(), 29 | }), 30 | ); 31 | 32 | // 4. Define a data type to monitor 33 | type YourDataType = { 34 | cratio: number; 35 | healthRatio: number; 36 | resourceId: ResourceId; 37 | }; 38 | 39 | const dataSourceMonitor: Monitor = Monitors.builder({ 40 | sdk: dialectAptosSdk, 41 | subscribersCacheTTL: Duration.fromObject({ seconds: 5 }), 42 | }) 43 | .defineDataSource() 44 | .poll((subscribers: ResourceId[]) => { 45 | const sourceData: SourceData[] = subscribers.map( 46 | (resourceId) => ({ 47 | data: { 48 | cratio: Math.random(), 49 | healthRatio: Math.random(), 50 | resourceId, 51 | }, 52 | groupingKey: resourceId.toString(), 53 | }), 54 | ); 55 | return Promise.resolve(sourceData); 56 | }, Duration.fromObject({ seconds: 3 })) 57 | .transform({ 58 | keys: ['cratio'], 59 | pipelines: [ 60 | Pipelines.threshold({ 61 | type: 'falling-edge', 62 | threshold: 0.5, 63 | }), 64 | ], 65 | }) 66 | .notify() 67 | .dialectThread( 68 | ({ value }) => { 69 | return { 70 | message: `Your cratio = ${value} below warning threshold`, 71 | }; 72 | }, 73 | { 74 | dispatch: 'unicast', 75 | to: ({ origin: { resourceId } }) => resourceId, 76 | }, 77 | ) 78 | .also() 79 | .transform({ 80 | keys: ['cratio'], 81 | pipelines: [ 82 | Pipelines.threshold({ 83 | type: 'rising-edge', 84 | threshold: 0.5, 85 | }), 86 | ], 87 | }) 88 | .notify() 89 | .dialectThread( 90 | ({ value }) => { 91 | return { 92 | message: `Your cratio = ${value} above warning threshold`, 93 | }; 94 | }, 95 | { 96 | dispatch: 'unicast', 97 | to: ({ origin: { resourceId } }) => resourceId, 98 | }, 99 | ) 100 | .and() 101 | .build(); 102 | dataSourceMonitor.start(); 103 | -------------------------------------------------------------------------------- /examples/001-data-source-monitor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectNotification, 3 | Monitor, 4 | Monitors, 5 | Pipelines, 6 | ResourceId, 7 | SourceData, 8 | } from '../src'; 9 | import { Duration } from 'luxon'; 10 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 11 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 12 | 13 | type DataType = { 14 | cratio: number; 15 | healthRatio: number; 16 | subscribers: ResourceId[]; 17 | }; 18 | 19 | const threshold = 0.5; 20 | 21 | const consoleNotificationSink = 22 | new ConsoleNotificationSink(); 23 | const dummySubscriberRepository = new DummySubscriberRepository(2); 24 | const monitor: Monitor = Monitors.builder({ 25 | subscriberRepository: dummySubscriberRepository, 26 | }) 27 | .defineDataSource() 28 | .poll((subscribers: ResourceId[]) => { 29 | const sourceData: SourceData[] = subscribers.map( 30 | (resourceId) => ({ 31 | data: { 32 | cratio: Math.random(), 33 | healthRatio: Math.random() * 10, 34 | subscribers, 35 | }, 36 | groupingKey: resourceId.toBase58(), 37 | }), 38 | ); 39 | return Promise.resolve(sourceData); 40 | }, Duration.fromObject({ seconds: 1 })) 41 | .transform({ 42 | keys: ['cratio'], 43 | pipelines: [ 44 | Pipelines.threshold({ 45 | type: 'falling-edge', 46 | threshold, 47 | }), 48 | ], 49 | }) 50 | .notify() 51 | .custom( 52 | ({ value }) => ({ 53 | message: `Your cratio = ${value} below warning threshold`, 54 | }), 55 | consoleNotificationSink, 56 | { dispatch: 'broadcast' }, 57 | ) 58 | .also() 59 | .transform({ 60 | keys: ['cratio'], 61 | pipelines: [ 62 | Pipelines.threshold({ 63 | type: 'rising-edge', 64 | threshold, 65 | }), 66 | ], 67 | }) 68 | .notify() 69 | .custom( 70 | ({ value }) => ({ 71 | message: `Your cratio = ${value} above warning threshold`, 72 | }), 73 | consoleNotificationSink, 74 | { dispatch: 'broadcast' }, 75 | ) 76 | .and() 77 | .build(); 78 | monitor.start(); 79 | -------------------------------------------------------------------------------- /examples/002-subscribers-monitor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectNotification, 3 | Monitors, 4 | Pipelines, 5 | SubscriberState, 6 | } from '../src'; 7 | import { Keypair } from '@solana/web3.js'; 8 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 9 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 10 | import { InMemorySubscriberRepository } from '../src/internal/in-memory-subscriber.repository'; 11 | import { Duration } from 'luxon'; 12 | 13 | // TODO: fixme 14 | const dummySubscriberRepository = new DummySubscriberRepository(); 15 | const consoleNotificationSink = 16 | new ConsoleNotificationSink(); 17 | const monitor = Monitors.builder({ 18 | subscriberRepository: InMemorySubscriberRepository.decorate( 19 | dummySubscriberRepository, 20 | Duration.fromMillis(1), 21 | ), 22 | }) 23 | .subscriberEvents() 24 | .transform({ 25 | keys: ['state'], 26 | pipelines: [Pipelines.notifyNewSubscribers()], 27 | }) 28 | .notify() 29 | .custom( 30 | ({ 31 | context: { 32 | origin: { resourceId }, 33 | }, 34 | }) => ({ 35 | message: `Hey ${resourceId}, welcome!`, 36 | }), 37 | consoleNotificationSink, 38 | { dispatch: 'unicast', to: ({ origin: { resourceId } }) => resourceId }, 39 | ) 40 | .and() 41 | .build(); 42 | 43 | monitor.start(); 44 | 45 | const pk = new Keypair().publicKey; 46 | 47 | setTimeout(() => { 48 | dummySubscriberRepository.addNewSubscriber({ 49 | resourceId: pk, 50 | }); 51 | }, 200); 52 | -------------------------------------------------------------------------------- /examples/003-custom-subscriber-repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ResourceId, 3 | Subscriber, 4 | SubscriberEventHandler, 5 | SubscriberRepository, 6 | } from '../src'; 7 | import { Keypair } from '@solana/web3.js'; 8 | 9 | export class DummySubscriberRepository implements SubscriberRepository { 10 | private readonly subscribers: Subscriber[] = []; 11 | private readonly onSubscriberAddedHandlers: SubscriberEventHandler[] = []; 12 | private readonly onSubscriberRemovedHandlers: SubscriberEventHandler[] = []; 13 | 14 | constructor(size: number = 2) { 15 | this.subscribers = Array(size) 16 | .fill(0) 17 | .map(() => { 18 | const resourceId = new Keypair().publicKey; 19 | return { 20 | resourceId, 21 | wallet: resourceId, 22 | }; 23 | }); 24 | } 25 | 26 | findAll(): Promise { 27 | return Promise.resolve(this.subscribers); 28 | } 29 | 30 | subscribe( 31 | onSubscriberAdded: SubscriberEventHandler, 32 | onSubscriberRemoved: SubscriberEventHandler, 33 | ): any { 34 | this.onSubscriberAddedHandlers.push(onSubscriberAdded); 35 | this.onSubscriberRemovedHandlers.push(onSubscriberRemoved); 36 | } 37 | 38 | addNewSubscriber(subscriber: Subscriber) { 39 | this.subscribers.push(subscriber); 40 | this.onSubscriberAddedHandlers.forEach((it) => it(subscriber)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /examples/004-custom-notification-sink.ts: -------------------------------------------------------------------------------- 1 | import { Notification, NotificationSink, ResourceId } from '../src'; 2 | 3 | // Custom notification sink implementing the NotificationSink interface 4 | export class ConsoleNotificationSink 5 | implements NotificationSink 6 | { 7 | // Define logic for to push notifications 8 | push(notification: N, recipients: ResourceId[]): Promise { 9 | // In this case, notifications sent to the console log 10 | console.log( 11 | `Got new notification ${JSON.stringify( 12 | notification, 13 | )} for recipients ${recipients}`, 14 | ); 15 | return Promise.resolve(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/005-custom-pipelines-using-operators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectNotification, 3 | Monitor, 4 | Monitors, 5 | Operators, 6 | Pipelines, 7 | PipeLogLevel, 8 | ResourceId, 9 | setPipeLogLevel, 10 | SourceData, 11 | } from '../src'; 12 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 13 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 14 | import { Duration } from 'luxon'; 15 | import { PublicKey } from '@solana/web3.js'; 16 | 17 | type DataType = { 18 | cratio: number; 19 | healthRatio: number; 20 | resourceId: PublicKey; 21 | }; 22 | 23 | setPipeLogLevel(PipeLogLevel.INFO); 24 | 25 | const consoleNotificationSink = 26 | new ConsoleNotificationSink(); 27 | const monitor: Monitor = Monitors.builder({ 28 | subscriberRepository: new DummySubscriberRepository(), 29 | }) 30 | .defineDataSource() 31 | .poll((subscribers: ResourceId[]) => { 32 | const sourceData: SourceData[] = subscribers.map( 33 | (resourceId) => ({ 34 | data: { 35 | cratio: Math.random(), 36 | healthRatio: Math.random() * 10, 37 | resourceId, 38 | }, 39 | groupingKey: resourceId.toBase58(), 40 | }), 41 | ); 42 | return Promise.resolve(sourceData); 43 | }, Duration.fromObject({ seconds: 1 })) 44 | .transform({ 45 | keys: ['cratio'], 46 | pipelines: [ 47 | Pipelines.createNew((upstream) => 48 | upstream 49 | .pipe(Operators.Utility.log(PipeLogLevel.INFO, 'upstream')) 50 | .pipe( 51 | ...Operators.Window.fixedTime( 52 | Duration.fromObject({ seconds: 3 }), 53 | ), 54 | ) 55 | .pipe(Operators.Utility.log(PipeLogLevel.INFO, ' time windowed')) 56 | .pipe(Operators.Aggregate.avg()) 57 | .pipe( 58 | Operators.Utility.log(PipeLogLevel.INFO, ' time windowed avg'), 59 | ) 60 | .pipe(Operators.Window.fixedSizeSliding(5)) 61 | .pipe( 62 | Operators.Utility.log(PipeLogLevel.INFO, ' sliding windowed'), 63 | ) 64 | .pipe(Operators.Aggregate.max()) 65 | .pipe( 66 | Operators.Utility.log( 67 | PipeLogLevel.INFO, 68 | ' sliding windowed max', 69 | ), 70 | ) 71 | .pipe(...Operators.Trigger.risingEdge(0.6)) 72 | .pipe(Operators.Utility.log(PipeLogLevel.INFO, ' rising edge')), 73 | ), 74 | ], 75 | }) 76 | .notify() 77 | .custom( 78 | ({ value }) => ({ 79 | message: ` notification ${value}`, 80 | }), 81 | consoleNotificationSink, 82 | { dispatch: 'unicast', to: ({ origin: { resourceId } }) => resourceId }, 83 | ) 84 | .and() 85 | .build(); 86 | monitor.start(); 87 | -------------------------------------------------------------------------------- /examples/006-increase-trigger.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Context, 3 | DialectNotification, 4 | Monitor, 5 | Monitors, 6 | Pipelines, 7 | ResourceId, 8 | SourceData, 9 | } from '../src'; 10 | import { Duration } from 'luxon'; 11 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 12 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 13 | 14 | type DataPool = { 15 | share: number; 16 | resourceId: ResourceId; 17 | }; 18 | 19 | let counter = 0; 20 | 21 | const threshold = 5; 22 | 23 | function getTriggerOutput(context: Context) { 24 | return context.trace.find((it) => it.type === 'trigger')?.output; 25 | } 26 | 27 | const consoleDataSink = new ConsoleNotificationSink(); 28 | const monitor: Monitor = Monitors.builder({ 29 | subscriberRepository: new DummySubscriberRepository(2), 30 | }) 31 | .defineDataSource() 32 | .poll((subscribers: ResourceId[]) => { 33 | // subscribers are only those users who created a dialect thread! 34 | const sourceData: SourceData[] = subscribers.map( 35 | (resourceId) => ({ 36 | data: { 37 | share: counter * counter, 38 | resourceId, 39 | }, 40 | groupingKey: resourceId.toBase58(), 41 | }), 42 | ); 43 | counter++; 44 | return Promise.resolve(sourceData); 45 | }, Duration.fromObject({ seconds: 1 })) 46 | .transform({ 47 | keys: ['share'], 48 | pipelines: [ 49 | Pipelines.threshold({ 50 | type: 'increase', 51 | threshold: 1, 52 | }), 53 | ], 54 | }) 55 | .notify() 56 | .custom( 57 | ({ value, context }) => ({ 58 | message: `Value: ${value} increase by ${getTriggerOutput(context)} `, 59 | }), 60 | consoleDataSink, 61 | { dispatch: 'broadcast' }, 62 | ) 63 | .and() 64 | .build(); 65 | monitor.start(); 66 | -------------------------------------------------------------------------------- /examples/007-pushy-data-source-monitor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectNotification, 3 | Monitor, 4 | Monitors, 5 | Pipelines, 6 | ResourceId, 7 | SourceData, 8 | } from '../src'; 9 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 10 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 11 | import { Subject } from 'rxjs'; 12 | import { Keypair } from '@solana/web3.js'; 13 | 14 | type DataType = { 15 | cratio: number; 16 | healthRatio: number; 17 | resourceId: ResourceId; 18 | }; 19 | 20 | const threshold = 0.5; 21 | 22 | const consoleNotificationSink = 23 | new ConsoleNotificationSink(); 24 | 25 | const subject = new Subject>(); 26 | 27 | const monitor: Monitor = Monitors.builder({ 28 | subscriberRepository: new DummySubscriberRepository(1), 29 | }) 30 | .defineDataSource() 31 | .push(subject) 32 | .transform({ 33 | keys: ['cratio'], 34 | pipelines: [ 35 | Pipelines.threshold({ 36 | type: 'rising-edge', 37 | threshold, 38 | }), 39 | ], 40 | }) 41 | .notify() 42 | .custom( 43 | ({ value }) => ({ 44 | message: `Your cratio = ${value} above warning threshold`, 45 | }), 46 | consoleNotificationSink, 47 | { 48 | dispatch: 'multicast', 49 | to: ({ origin: { resourceId } }) => [ 50 | resourceId, 51 | Keypair.generate().publicKey, 52 | ], 53 | }, 54 | ) 55 | .and() 56 | .build(); 57 | monitor.start(); 58 | 59 | const publicKey = Keypair.generate().publicKey; 60 | const d1: SourceData = { 61 | data: { cratio: 0, healthRatio: 2, resourceId: publicKey }, 62 | groupingKey: publicKey.toBase58(), 63 | }; 64 | setTimeout(() => { 65 | subject.next(d1); 66 | }, 100); 67 | 68 | const d2: SourceData = { 69 | data: { cratio: 1, healthRatio: 0, resourceId: publicKey }, 70 | groupingKey: publicKey.toBase58(), 71 | }; 72 | setTimeout(() => { 73 | subject.next(d2); 74 | }, 200); 75 | -------------------------------------------------------------------------------- /examples/008-diff-pipeline.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectNotification, 3 | Diff, 4 | Monitor, 5 | Monitors, 6 | Pipelines, 7 | ResourceId, 8 | SourceData, 9 | } from '../src'; 10 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 11 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 12 | import { Subject } from 'rxjs'; 13 | import { Keypair, PublicKey } from '@solana/web3.js'; 14 | 15 | type DataType = { 16 | attribute: NestedObject[]; 17 | resourceId: ResourceId; 18 | }; 19 | 20 | interface NestedObject { 21 | publicKey: PublicKey; 22 | } 23 | 24 | const consoleNotificationSink = 25 | new ConsoleNotificationSink(); 26 | 27 | const subject = new Subject>(); 28 | 29 | const monitor: Monitor = Monitors.builder({ 30 | subscriberRepository: new DummySubscriberRepository(3), 31 | }) 32 | .defineDataSource() 33 | .push(subject) 34 | .transform>({ 35 | keys: ['attribute'], 36 | pipelines: [Pipelines.diff((e1, e2) => e1.publicKey.equals(e2.publicKey))], 37 | }) 38 | .notify() 39 | .custom( 40 | ({ value }) => ({ 41 | message: `Added: ${value.added.map( 42 | (it) => it.publicKey, 43 | )}, removed: ${value.removed.map((it) => it.publicKey)}`, 44 | }), 45 | consoleNotificationSink, 46 | { dispatch: 'unicast', to: ({ origin: { resourceId } }) => resourceId }, 47 | ) 48 | .also() 49 | .transform({ 50 | keys: ['attribute'], 51 | pipelines: [Pipelines.added((e1, e2) => e1.publicKey.equals(e2.publicKey))], 52 | }) 53 | .notify() 54 | .custom( 55 | ({ value }) => ({ 56 | message: `Added: ${value.map((it) => it.publicKey)}`, 57 | }), 58 | consoleNotificationSink, 59 | { dispatch: 'unicast', to: ({ origin: { resourceId } }) => resourceId }, 60 | ) 61 | .and() 62 | .build(); 63 | monitor.start(); 64 | 65 | const publicKey = Keypair.generate().publicKey; 66 | const pk1 = new Keypair().publicKey; 67 | const pk2 = new Keypair().publicKey; 68 | const pk3 = new Keypair().publicKey; 69 | 70 | console.log(pk1.toBase58(), pk2.toBase58(), pk3.toBase58()); 71 | const d1: SourceData = { 72 | data: { 73 | attribute: [{ publicKey: pk1 }, { publicKey: pk2 }], 74 | resourceId: publicKey, 75 | }, 76 | groupingKey: publicKey.toBase58(), 77 | }; 78 | const d2: SourceData = { 79 | data: { 80 | attribute: [{ publicKey: pk3 }], 81 | resourceId: publicKey, 82 | }, 83 | groupingKey: publicKey.toBase58(), 84 | }; 85 | setTimeout(() => { 86 | subject.next(d1); 87 | }, 500); 88 | 89 | setTimeout(() => { 90 | subject.next(d2); 91 | }, 1000); 92 | 93 | setTimeout(() => { 94 | subject.next(d1); 95 | }, 1500); 96 | -------------------------------------------------------------------------------- /examples/009-change-pipeline.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Change, 3 | DialectNotification, 4 | Monitor, 5 | Monitors, 6 | Pipelines, 7 | ResourceId, 8 | SourceData, 9 | } from '../src'; 10 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 11 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 12 | import { Subject } from 'rxjs'; 13 | import { Keypair, PublicKey } from '@solana/web3.js'; 14 | 15 | type DataType = { 16 | attribute: PublicKey; 17 | resourceId: ResourceId; 18 | }; 19 | 20 | const consoleNotificationSink = 21 | new ConsoleNotificationSink(); 22 | 23 | const subject = new Subject>(); 24 | 25 | const monitor: Monitor = Monitors.builder({ 26 | subscriberRepository: new DummySubscriberRepository(3), 27 | }) 28 | .defineDataSource() 29 | .push(subject) 30 | .transform>({ 31 | keys: ['attribute'], 32 | pipelines: [Pipelines.change((e1, e2) => e1.equals(e2))], 33 | }) 34 | .notify() 35 | .custom( 36 | ({ value, context }) => ({ 37 | message: `Changed from: ${value.prev.toBase58()} to ${value.current.toBase58()}, origin: ${JSON.stringify( 38 | context.origin, 39 | )}`, 40 | }), 41 | consoleNotificationSink, 42 | { dispatch: 'unicast', to: ({ origin: { resourceId } }) => resourceId }, 43 | ) 44 | .and() 45 | .build(); 46 | monitor.start(); 47 | 48 | const publicKey = Keypair.generate().publicKey; 49 | const pk1 = new Keypair().publicKey; 50 | const pk2 = new Keypair().publicKey; 51 | 52 | console.log(pk1.toBase58(), pk2.toBase58()); 53 | const d1: SourceData = { 54 | data: { 55 | attribute: pk1, 56 | resourceId: publicKey, 57 | }, 58 | groupingKey: publicKey.toBase58(), 59 | }; 60 | const d2: SourceData = { 61 | data: { 62 | attribute: pk2, 63 | resourceId: publicKey, 64 | }, 65 | groupingKey: publicKey.toBase58(), 66 | }; 67 | setTimeout(() => { 68 | subject.next(d1); 69 | }, 500); 70 | 71 | setTimeout(() => { 72 | subject.next(d2); 73 | }, 1000); 74 | 75 | setTimeout(() => { 76 | subject.next(d2); 77 | }, 1500); 78 | 79 | setTimeout(() => { 80 | subject.next(d1); 81 | }, 2000); 82 | -------------------------------------------------------------------------------- /examples/010-email-notification-monitor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectNotification, 3 | Monitor, 4 | Monitors, 5 | Pipelines, 6 | ResourceId, 7 | SourceData, 8 | } from '../src'; 9 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 10 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 11 | import { Observable } from 'rxjs'; 12 | import { Keypair } from '@solana/web3.js'; 13 | 14 | type DataType = { 15 | cratio: number; 16 | healthRatio: number; 17 | resourceId: ResourceId; 18 | }; 19 | 20 | const threshold = 0.5; 21 | 22 | const consoleNotificationSink = 23 | new ConsoleNotificationSink(); 24 | 25 | const monitor: Monitor = Monitors.builder({ 26 | subscriberRepository: new DummySubscriberRepository(1), 27 | sinks: { 28 | email: { 29 | senderEmail: 'hello@dialect.to', 30 | apiToken: process.env.EMAIL_SINK_TOKEN!, 31 | }, 32 | }, 33 | }) 34 | .defineDataSource() 35 | .push( 36 | new Observable((subscriber) => { 37 | const publicKey = Keypair.generate().publicKey; 38 | const d1: SourceData = { 39 | data: { cratio: 0, healthRatio: 2, resourceId: publicKey }, 40 | groupingKey: publicKey.toBase58(), 41 | }; 42 | const d2: SourceData = { 43 | data: { cratio: 1, healthRatio: 0, resourceId: publicKey }, 44 | groupingKey: publicKey.toBase58(), 45 | }; 46 | subscriber.next(d1); 47 | subscriber.next(d2); 48 | }), 49 | ) 50 | .transform({ 51 | keys: ['cratio'], 52 | pipelines: [ 53 | Pipelines.threshold({ 54 | type: 'rising-edge', 55 | threshold, 56 | }), 57 | ], 58 | }) 59 | .notify() 60 | .email( 61 | ({ value }) => ({ 62 | subject: '[WARNING] Cratio above warning threshold', 63 | text: `Your cratio = ${value} above warning threshold`, 64 | }), 65 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 66 | ) 67 | .dialectThread( 68 | ({ value }) => ({ 69 | message: `Your cratio = ${value} above warning threshold`, 70 | }), 71 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 72 | ) 73 | .custom( 74 | ({ value }) => ({ 75 | message: `Your cratio = ${value} above warning threshold`, 76 | }), 77 | consoleNotificationSink, 78 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 79 | ) 80 | .and() 81 | .build(); 82 | monitor.start(); 83 | -------------------------------------------------------------------------------- /examples/011-sms-notification-monitor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectNotification, 3 | Monitor, 4 | Monitors, 5 | Pipelines, 6 | ResourceId, 7 | SourceData, 8 | } from '../src'; 9 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 10 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 11 | import { Observable } from 'rxjs'; 12 | import { Keypair } from '@solana/web3.js'; 13 | 14 | type DataType = { 15 | cratio: number; 16 | healthRatio: number; 17 | resourceId: ResourceId; 18 | }; 19 | 20 | const threshold = 0.5; 21 | 22 | const consoleNotificationSink = 23 | new ConsoleNotificationSink(); 24 | 25 | const monitor: Monitor = Monitors.builder({ 26 | subscriberRepository: new DummySubscriberRepository(1), 27 | sinks: { 28 | sms: { 29 | twilioUsername: process.env.TWILIO_ACCOUNT_SID!, 30 | twilioPassword: process.env.TWILIO_AUTH_TOKEN!, 31 | senderSmsNumber: process.env.TWILIO_SMS_SENDER!, 32 | }, 33 | }, 34 | }) 35 | .defineDataSource() 36 | .push( 37 | new Observable((subscriber) => { 38 | const publicKey = Keypair.generate().publicKey; 39 | const d1: SourceData = { 40 | data: { cratio: 0, healthRatio: 2, resourceId: publicKey }, 41 | groupingKey: publicKey.toBase58(), 42 | }; 43 | const d2: SourceData = { 44 | data: { cratio: 1, healthRatio: 0, resourceId: publicKey }, 45 | groupingKey: publicKey.toBase58(), 46 | }; 47 | subscriber.next(d1); 48 | subscriber.next(d2); 49 | }), 50 | ) 51 | .transform({ 52 | keys: ['cratio'], 53 | pipelines: [ 54 | Pipelines.threshold({ 55 | type: 'rising-edge', 56 | threshold, 57 | }), 58 | ], 59 | }) 60 | .notify() 61 | .sms( 62 | ({ value }) => ({ 63 | body: `[WARNING] Your cratio = ${value} above warning threshold`, 64 | }), 65 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 66 | ) 67 | .custom( 68 | ({ value }) => ({ 69 | message: `Your cratio = ${value} above warning threshold`, 70 | }), 71 | consoleNotificationSink, 72 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 73 | ) 74 | .and() 75 | .build(); 76 | monitor.start(); 77 | -------------------------------------------------------------------------------- /examples/011-telegram-notification-monitor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectNotification, 3 | Monitor, 4 | Monitors, 5 | Pipelines, 6 | ResourceId, 7 | SourceData, 8 | } from '../src'; 9 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 10 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 11 | import { Observable } from 'rxjs'; 12 | import { Keypair } from '@solana/web3.js'; 13 | 14 | type DataType = { 15 | cratio: number; 16 | healthRatio: number; 17 | resourceId: ResourceId; 18 | }; 19 | 20 | const threshold = 0.5; 21 | 22 | const consoleNotificationSink = 23 | new ConsoleNotificationSink(); 24 | 25 | const monitor: Monitor = Monitors.builder({ 26 | subscriberRepository: new DummySubscriberRepository(1), 27 | sinks: { 28 | telegram: { 29 | telegramBotToken: process.env.TELEGRAM_BOT_KEY!, 30 | }, 31 | }, 32 | }) 33 | .defineDataSource() 34 | .push( 35 | new Observable((subscriber) => { 36 | const publicKey = Keypair.generate().publicKey; 37 | const d1: SourceData = { 38 | data: { cratio: 0, healthRatio: 2, resourceId: publicKey }, 39 | groupingKey: publicKey.toBase58(), 40 | }; 41 | const d2: SourceData = { 42 | data: { cratio: 1, healthRatio: 0, resourceId: publicKey }, 43 | groupingKey: publicKey.toBase58(), 44 | }; 45 | subscriber.next(d1); 46 | subscriber.next(d2); 47 | }), 48 | ) 49 | .transform({ 50 | keys: ['cratio'], 51 | pipelines: [ 52 | Pipelines.threshold({ 53 | type: 'rising-edge', 54 | threshold, 55 | }), 56 | ], 57 | }) 58 | .notify() 59 | .telegram( 60 | ({ value }) => ({ 61 | body: `[WARNING] Your cratio = ${value} above warning threshold`, 62 | }), 63 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 64 | ) 65 | .custom( 66 | ({ value }) => ({ 67 | message: `Your cratio = ${value} above warning threshold`, 68 | }), 69 | consoleNotificationSink, 70 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 71 | ) 72 | .and() 73 | .build(); 74 | monitor.start(); 75 | -------------------------------------------------------------------------------- /examples/012-all-web2-notification-monitor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectNotification, 3 | Monitor, 4 | Monitors, 5 | Pipelines, 6 | ResourceId, 7 | SourceData, 8 | } from '../src'; 9 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 10 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 11 | import { Observable } from 'rxjs'; 12 | import { Keypair } from '@solana/web3.js'; 13 | 14 | type DataType = { 15 | cratio: number; 16 | healthRatio: number; 17 | resourceId: ResourceId; 18 | }; 19 | 20 | const threshold = 0.5; 21 | 22 | const consoleNotificationSink = 23 | new ConsoleNotificationSink(); 24 | 25 | const monitor: Monitor = Monitors.builder({ 26 | subscriberRepository: new DummySubscriberRepository(1), 27 | sinks: { 28 | email: { 29 | senderEmail: 'hello@dialect.to', 30 | apiToken: process.env.EMAIL_SINK_TOKEN!, 31 | }, 32 | telegram: { 33 | telegramBotToken: process.env.TELEGRAM_BOT_KEY!, 34 | }, 35 | sms: { 36 | twilioUsername: process.env.TWILIO_ACCOUNT_SID!, 37 | twilioPassword: process.env.TWILIO_AUTH_TOKEN!, 38 | senderSmsNumber: process.env.TWILIO_SMS_SENDER!, 39 | }, 40 | }, 41 | }) 42 | .defineDataSource() 43 | .push( 44 | new Observable((subscriber) => { 45 | const publicKey = Keypair.generate().publicKey; 46 | const d1: SourceData = { 47 | data: { cratio: 0, healthRatio: 2, resourceId: publicKey }, 48 | groupingKey: publicKey.toBase58(), 49 | }; 50 | const d2: SourceData = { 51 | data: { cratio: 1, healthRatio: 0, resourceId: publicKey }, 52 | groupingKey: publicKey.toBase58(), 53 | }; 54 | subscriber.next(d1); 55 | subscriber.next(d2); 56 | }), 57 | ) 58 | .transform({ 59 | keys: ['cratio'], 60 | pipelines: [ 61 | Pipelines.threshold({ 62 | type: 'rising-edge', 63 | threshold, 64 | }), 65 | ], 66 | }) 67 | .notify() 68 | .email( 69 | ({ value }) => ({ 70 | subject: '[WARNING] Cratio above warning threshold', 71 | text: `Your cratio = ${value} above warning threshold`, 72 | }), 73 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 74 | ) 75 | .dialectThread( 76 | ({ value }) => ({ 77 | message: `Your cratio = ${value} above warning threshold`, 78 | }), 79 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 80 | ) 81 | .custom( 82 | ({ value }) => ({ 83 | message: `Your cratio = ${value} above warning threshold`, 84 | }), 85 | consoleNotificationSink, 86 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 87 | ) 88 | .and() 89 | .build(); 90 | monitor.start(); 91 | -------------------------------------------------------------------------------- /examples/013-solflare-notification-monitor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectNotification, 3 | Monitor, 4 | Monitors, 5 | Pipelines, 6 | ResourceId, 7 | SourceData, 8 | } from '../src'; 9 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 10 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 11 | import { Observable } from 'rxjs'; 12 | import { Keypair, PublicKey } from '@solana/web3.js'; 13 | 14 | type DataType = { 15 | cratio: number; 16 | healthRatio: number; 17 | resourceId: ResourceId; 18 | }; 19 | 20 | const threshold = 0.5; 21 | 22 | const consoleNotificationSink = 23 | new ConsoleNotificationSink(); 24 | 25 | const dummySubscriberRepository = new DummySubscriberRepository(1); 26 | 27 | const publicKey = Keypair.generate().publicKey; 28 | // const publicKey = new PublicKey('AC...sf'); 29 | 30 | const monitor: Monitor = Monitors.builder({ 31 | subscriberRepository: dummySubscriberRepository, 32 | sinks: { 33 | solflare: { 34 | apiKey: process.env.SOLFLARE_SOLCAST_API_KEY!, 35 | // apiUrl: 'http://localhost:4000/v1', 36 | }, 37 | }, 38 | }) 39 | .defineDataSource() 40 | .push( 41 | new Observable((subscriber) => { 42 | const d1: SourceData = { 43 | data: { cratio: 0, healthRatio: 2, resourceId: publicKey }, 44 | groupingKey: publicKey.toBase58(), 45 | }; 46 | const d2: SourceData = { 47 | data: { cratio: 1, healthRatio: 0, resourceId: publicKey }, 48 | groupingKey: publicKey.toBase58(), 49 | }; 50 | subscriber.next(d1); 51 | subscriber.next(d2); 52 | }), 53 | ) 54 | .transform({ 55 | keys: ['cratio'], 56 | pipelines: [ 57 | Pipelines.threshold({ 58 | type: 'rising-edge', 59 | threshold, 60 | }), 61 | ], 62 | }) 63 | .notify() 64 | .solflare( 65 | ({ value }) => ({ 66 | title: 'dApp cratio warning', 67 | body: `Your cratio = ${value} above warning threshold`, 68 | actionUrl: null, 69 | }), 70 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 71 | ) 72 | .custom( 73 | ({ value }) => ({ 74 | message: `Your cratio = ${value} above warning threshold`, 75 | }), 76 | consoleNotificationSink, 77 | { dispatch: 'unicast', to: ({ origin }) => origin.resourceId }, 78 | ) 79 | .and() 80 | .build(); 81 | monitor.start(); 82 | -------------------------------------------------------------------------------- /examples/014-dialect-sdk-notification-monitor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectNotification, 3 | Monitor, 4 | Monitors, 5 | Pipelines, 6 | ResourceId, 7 | SourceData, 8 | } from '../src'; 9 | import { DummySubscriberRepository } from './003-custom-subscriber-repository'; 10 | import { ConsoleNotificationSink } from './004-custom-notification-sink'; 11 | import { Observable } from 'rxjs'; 12 | import { Keypair, PublicKey } from '@solana/web3.js'; 13 | 14 | // Common Dialect SDK imports 15 | import { 16 | DappMessageActionType, 17 | Dialect, 18 | DialectCloudEnvironment, 19 | DialectSdk, 20 | } from '@dialectlabs/sdk'; 21 | 22 | // Solana-specific imports 23 | import { 24 | NodeDialectSolanaWalletAdapter, 25 | Solana, 26 | SolanaSdkFactory, 27 | } from '@dialectlabs/blockchain-sdk-solana'; 28 | 29 | type DataType = { 30 | cratio: number; 31 | healthRatio: number; 32 | resourceId: ResourceId; 33 | }; 34 | 35 | const threshold = 0.5; 36 | 37 | // 3. Create Dialect Solana SDK 38 | const environment: DialectCloudEnvironment = 'development'; 39 | const dialectSolanaSdk: DialectSdk = Dialect.sdk( 40 | { 41 | environment, 42 | }, 43 | SolanaSdkFactory.create({ 44 | // IMPORTANT: must set environment variable DIALECT_SDK_CREDENTIALS 45 | // to your dapp's Solana messaging wallet keypair e.g. [170,23, . . . ,300] 46 | wallet: NodeDialectSolanaWalletAdapter.create(), 47 | }), 48 | ); 49 | 50 | const consoleNotificationSink = 51 | new ConsoleNotificationSink(); 52 | 53 | const dummySubscriberRepository = new DummySubscriberRepository(1); 54 | 55 | const publicKey = Keypair.generate().publicKey; 56 | 57 | const monitor: Monitor = Monitors.builder({ 58 | sdk: dialectSolanaSdk, 59 | // subscriberRepository: dummySubscriberRepository, 60 | }) 61 | .defineDataSource() 62 | .push( 63 | new Observable((subscriber) => { 64 | const d1: SourceData = { 65 | data: { cratio: 0, healthRatio: 2, resourceId: publicKey }, 66 | groupingKey: publicKey.toBase58(), 67 | }; 68 | const d2: SourceData = { 69 | data: { cratio: 1, healthRatio: 0, resourceId: publicKey }, 70 | groupingKey: publicKey.toBase58(), 71 | }; 72 | subscriber.next(d1); 73 | subscriber.next(d2); 74 | }), 75 | ) 76 | .transform({ 77 | keys: ['cratio'], 78 | pipelines: [ 79 | Pipelines.threshold({ 80 | type: 'rising-edge', 81 | threshold, 82 | }), 83 | ], 84 | }) 85 | .notify({ 86 | type: { 87 | id: '434ee971-44ad-4021-98fe-3140a627bca8', 88 | }, 89 | }) 90 | .dialectSdk( 91 | ({ value }) => ({ 92 | title: 'dApp cratio warning', 93 | message: `Your cratio = ${value} above warning threshold`, 94 | }), 95 | { 96 | dispatch: 'unicast', 97 | to: ({ origin }) => 98 | new PublicKey('6MKeaLnTnhXM6Qo8gHEgbeqeoUqbg4Re4FL5UHXMjetJ'), 99 | }, 100 | ) 101 | .dialectSdk( 102 | ({ value }) => ({ 103 | title: 'dApp cratio warning', 104 | message: `Your cratio = ${value} above warning threshold`, 105 | }), 106 | { 107 | dispatch: 'unicast', 108 | to: ({ origin }) => 109 | new PublicKey('6MKeaLnTnhXM6Qo8gHEgbeqeoUqbg4Re4FL5UHXMjetJ'), 110 | }, 111 | ) 112 | .dialectSdk( 113 | ({ value }) => ({ 114 | title: 'dApp cratio warning', 115 | message: `Your cratio = ${value} above warning threshold`, 116 | actions: { 117 | type: DappMessageActionType.LINK, 118 | links: [ 119 | { 120 | url: 'https://dialect.to/', 121 | label: 'Open Dialect', 122 | }, 123 | ], 124 | }, 125 | }), 126 | { 127 | dispatch: 'unicast', 128 | to: ({ origin }) => 129 | new PublicKey('6MKeaLnTnhXM6Qo8gHEgbeqeoUqbg4Re4FL5UHXMjetJ'), 130 | }, 131 | ) 132 | .custom( 133 | ({ value }) => ({ 134 | message: `Your cratio = ${value} above warning threshold`, 135 | }), 136 | consoleNotificationSink, 137 | { 138 | dispatch: 'unicast', 139 | to: ({ origin }) => 140 | new PublicKey('6MKeaLnTnhXM6Qo8gHEgbeqeoUqbg4Re4FL5UHXMjetJ'), 141 | }, 142 | ) 143 | .and() 144 | .build(); 145 | monitor.start(); 146 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | // Sync object 3 | const config: Config.InitialOptions = { 4 | verbose: true, 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest', 7 | }, 8 | }; 9 | export default config; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dialectlabs/monitor", 3 | "version": "3.5.1", 4 | "repository": "git@github.com:dialectlabs/monitor.git", 5 | "author": "dialectlabs", 6 | "license": "Apache-2.0", 7 | "module": "./lib/cjs/index.js", 8 | "main": "./lib/cjs/index.js", 9 | "types": "./lib/cjs/index.d.ts", 10 | "scripts": { 11 | "build": "rm -rf lib && tsc" 12 | }, 13 | "dependencies": { 14 | "@dialectlabs/blockchain-sdk-aptos": "^1.0.3", 15 | "@dialectlabs/blockchain-sdk-solana": "^1.2.0", 16 | "@dialectlabs/sdk": "^1.9.1", 17 | "@sendgrid/mail": "^7.6.2", 18 | "@solana/web3.js": "^1.91.7", 19 | "axios": "^1.6.8", 20 | "lodash": "^4.17.21", 21 | "luxon": "^2.5.2", 22 | "rxjs": "^7.5.2", 23 | "telegraf": "^4.16.3", 24 | "twilio": "^3.84.1" 25 | }, 26 | "devDependencies": { 27 | "@types/bs58": "^4.0.4", 28 | "@types/bytebuffer": "^5.0.48", 29 | "@types/ed2curve": "^0.2.4", 30 | "@types/jest": "^27.5.2", 31 | "@types/lodash": "^4.17.0", 32 | "@types/luxon": "^2.4.0", 33 | "@types/node": "^17.0.45", 34 | "jest": "^27.5.1", 35 | "prettier": "^2.8.8", 36 | "ts-jest": "^27.1.3", 37 | "ts-node": "^10.4.0", 38 | "typescript": "^4.9.5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/data-model.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey } from '@solana/web3.js'; 2 | import { Subscriber } from './ports'; 3 | 4 | /** 5 | * A reference to any on-chain resource i.e.g user or a dApp, just an alias to PublicKey 6 | */ 7 | export type ResourceId = PublicKey; 8 | 9 | /** 10 | * Any data bound to a specific on chain resource that may be stored in account 11 | * @typeParam T data type provided by data source 12 | */ 13 | export interface SourceData { 14 | data: T; 15 | groupingKey: string; 16 | } 17 | 18 | /** 19 | * Any data bound to a specific on chain resource that may be stored in account 20 | * @typeParam T data type provided by data source 21 | */ 22 | export interface Data { 23 | value: V; 24 | context: Context; 25 | } 26 | 27 | /** 28 | * A holder for any context data that need to be preserved through all pipeline transformations 29 | */ 30 | export interface Context { 31 | origin: T; 32 | groupingKey: string; 33 | trace: Trace[]; 34 | subscribers: Subscriber[]; 35 | } 36 | 37 | /** 38 | * A holder for recording any context information about execution of transformations steps 39 | */ 40 | export type Trace = TriggerTrace; 41 | 42 | /** 43 | * A holder for trigger transformation context i.e. trigger input and output 44 | */ 45 | export interface TriggerTrace { 46 | type: 'trigger'; 47 | input: number[]; 48 | output: number; 49 | } 50 | 51 | export interface Notification {} 52 | 53 | /** 54 | * An event that is fired when something changes in subscriber state e.g. new subscriber is added 55 | */ 56 | export interface SubscriberEvent { 57 | resourceId: ResourceId; 58 | state: SubscriberState; 59 | } 60 | 61 | export type SubscriberState = 'added' | 'removed'; 62 | -------------------------------------------------------------------------------- /src/dialect-sdk-notification-sink.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NotificationSink, 3 | NotificationSinkMetadata, 4 | SubscriberRepository, 5 | } from './ports'; 6 | import { Notification, ResourceId } from './data-model'; 7 | import { 8 | BlockchainSdk, 9 | Dapp, 10 | DappMessageLinksAction, 11 | DialectSdk, 12 | IllegalStateError, 13 | } from '@dialectlabs/sdk'; 14 | import { NotificationMetadata } from './monitor-builder'; 15 | import { uniqBy } from 'lodash'; 16 | 17 | export interface DialectSdkNotification extends Notification { 18 | title: string; 19 | message: string; 20 | actions?: DappMessageLinksAction; 21 | } 22 | 23 | export class DialectSdkNotificationSink 24 | implements NotificationSink 25 | { 26 | private dapp: Dapp | null = null; 27 | 28 | constructor( 29 | private readonly sdk: DialectSdk, 30 | private readonly subscriberRepository: SubscriberRepository, 31 | ) {} 32 | 33 | async push( 34 | { title, message, actions }: DialectSdkNotification, 35 | recipients: ResourceId[], 36 | { dispatchType, notificationMetadata }: NotificationSinkMetadata, 37 | ) { 38 | try { 39 | const notificationTypeId = await this.tryResolveNotificationTypeId( 40 | notificationMetadata, 41 | ); 42 | const dapp = await this.lookupDapp(); 43 | if (dispatchType === 'unicast') { 44 | const theOnlyRecipient = recipients[0]; 45 | if (!theOnlyRecipient) { 46 | throw new IllegalStateError( 47 | `No recipient specified for unicast notification`, 48 | ); 49 | } 50 | await dapp.messages.send({ 51 | title: title, 52 | message: message, 53 | recipient: theOnlyRecipient.toBase58(), 54 | notificationTypeId, 55 | actionsV2: actions, 56 | }); 57 | } else if (dispatchType === 'multicast') { 58 | if (recipients.length === 0) { 59 | return; 60 | } 61 | await dapp.messages.send({ 62 | title: title, 63 | message: message, 64 | recipients: recipients.map((it) => it.toBase58()), 65 | notificationTypeId, 66 | actionsV2: actions, 67 | }); 68 | } else if (dispatchType === 'broadcast') { 69 | await dapp.messages.send({ 70 | title: title, 71 | message: message, 72 | notificationTypeId, 73 | actionsV2: actions, 74 | }); 75 | } else { 76 | console.error( 77 | `Dialect SDK notification sink does not support this dispatch type: ${dispatchType}.`, 78 | ); 79 | } 80 | } catch (e) { 81 | console.error( 82 | `Failed to send dialect sdk notification, reason: ${JSON.stringify(e)}`, 83 | ); 84 | } 85 | return; 86 | } 87 | 88 | private tryResolveNotificationTypeId( 89 | notificationMetadata?: NotificationMetadata, 90 | ) { 91 | const notificationTypeId = notificationMetadata?.type.id; 92 | if (notificationTypeId) { 93 | return this.resolveNotificationTypeId(notificationTypeId); 94 | } 95 | } 96 | 97 | private async resolveNotificationTypeId(notificationTypeId: string) { 98 | const subscribers = await this.subscriberRepository.findAll(); 99 | const availableNotificationTypes = uniqBy( 100 | subscribers 101 | .flatMap((it) => it.notificationSubscriptions ?? []) 102 | .map((it) => it.notificationType), 103 | (it) => it.id, 104 | ); 105 | const notificationType = availableNotificationTypes.find( 106 | (it) => 107 | it.humanReadableId.toLowerCase() === notificationTypeId.toLowerCase() || 108 | it.id === notificationTypeId, 109 | ); 110 | if (availableNotificationTypes.length > 0 && !notificationType) { 111 | throw new IllegalStateError( 112 | `Unknown notification type ${notificationTypeId}, must be one of [${availableNotificationTypes.map( 113 | (it) => it.humanReadableId, 114 | )}] or one of [${availableNotificationTypes.map((it) => it.id)}]`, 115 | ); 116 | } 117 | return notificationType?.id; 118 | } 119 | 120 | private async lookupDapp() { 121 | if (!this.dapp) { 122 | const dapp = await this.sdk.dapps.find(); 123 | if (!dapp) { 124 | throw new IllegalStateError( 125 | `Dapp ${this.sdk.wallet.address} not registered in dialect cloud ${this.sdk.config.dialectCloud}`, 126 | ); 127 | } 128 | this.dapp = dapp; 129 | } 130 | return this.dapp; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/dialect-sdk-subscriber.repository.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey } from '@solana/web3.js'; 2 | import { 3 | Subscriber, 4 | SubscriberEventHandler, 5 | SubscriberNotificationSubscription, 6 | SubscriberRepository, 7 | } from './ports'; 8 | import _ from 'lodash'; 9 | import { 10 | AddressType, 11 | BlockchainSdk, 12 | Dapp, 13 | DialectSdk, 14 | IllegalStateError, 15 | } from '@dialectlabs/sdk'; 16 | 17 | export class DialectSdkSubscriberRepository implements SubscriberRepository { 18 | private dapp: Dapp | null = null; 19 | 20 | constructor(private sdk: DialectSdk) {} 21 | 22 | subscribe( 23 | onSubscriberAdded?: SubscriberEventHandler, 24 | onSubscriberRemoved?: SubscriberEventHandler, 25 | ) { 26 | throw new Error('Method not implemented.'); 27 | } 28 | 29 | async findAll(): Promise { 30 | const addressSubscribers = await this.findAddressSubscribers(); 31 | const notificationSubscribers = 32 | await this.findNotificationTypeSubscribers(); 33 | return _.values( 34 | _.merge( 35 | _.keyBy(addressSubscribers, (it) => it.resourceId.toBase58()), 36 | _.keyBy(notificationSubscribers, (it) => it.resourceId.toBase58()), 37 | ), 38 | ); 39 | } 40 | 41 | private async findAddressSubscribers(): Promise { 42 | const dapp = await this.lookupDapp(); 43 | const dappAddresses = await dapp.dappAddresses.findAll(); 44 | return _(dappAddresses) 45 | .filter(({ enabled, address: { verified } }) => enabled && verified) 46 | .map((it) => ({ 47 | resourceId: it.address.wallet.address, 48 | ...(it.address.type === AddressType.Email && { 49 | email: it.address.value, 50 | }), 51 | ...(it.address.type === AddressType.Telegram && { 52 | telegramChatId: it.channelId, 53 | }), 54 | ...(it.address.type === AddressType.PhoneNumber && { 55 | phoneNumber: it.address.value, 56 | }), 57 | ...(it.address.type === AddressType.Wallet && { 58 | wallet: new PublicKey(it.address.value), 59 | }), 60 | })) 61 | .groupBy('resourceId') 62 | .mapValues((s, resourceId) => ({ 63 | resourceId: new PublicKey(resourceId), 64 | telegramChatId: s 65 | .map(({ telegramChatId }) => telegramChatId) 66 | .find((it) => it), 67 | phoneNumber: s.map(({ phoneNumber }) => phoneNumber).find((it) => it), 68 | email: s.map(({ email }) => email).find((it) => it), 69 | wallet: s.map(({ wallet }) => wallet).find((it) => it), 70 | })) 71 | .values() 72 | .value(); 73 | } 74 | 75 | private async findNotificationTypeSubscribers(): Promise { 76 | const dapp = await this.lookupDapp(); 77 | const notificationTypes = await dapp.notificationTypes.findAll(); 78 | if (notificationTypes.length === 0) { 79 | return []; 80 | } 81 | const dappNotificationSubscriptions = 82 | await dapp.notificationSubscriptions.findAll(); 83 | return _(dappNotificationSubscriptions) 84 | .flatMap(({ subscriptions, notificationType }) => 85 | subscriptions.map((subscription) => { 86 | const notificationSubscription: SubscriberNotificationSubscription = { 87 | notificationType: { 88 | id: notificationType.id, 89 | humanReadableId: notificationType.humanReadableId, 90 | }, 91 | config: subscription.config, 92 | }; 93 | return { 94 | resourceId: subscription.wallet.address, 95 | subscription: notificationSubscription, 96 | }; 97 | }), 98 | ) 99 | .groupBy('resourceId') 100 | .mapValues((s, resourceId) => { 101 | const subscriber: Subscriber = { 102 | resourceId: new PublicKey(resourceId), 103 | notificationSubscriptions: s 104 | .filter((it) => it.subscription.config.enabled) 105 | .map((it) => it.subscription), 106 | }; 107 | return subscriber; 108 | }) 109 | .values() 110 | .value(); 111 | } 112 | 113 | private async lookupDapp() { 114 | if (!this.dapp) { 115 | const dapp = await this.sdk.dapps.find(); 116 | if (!dapp) { 117 | throw new IllegalStateError( 118 | `Dapp ${this.sdk.wallet.address} not registered in dialect cloud ${this.sdk.config.dialectCloud}`, 119 | ); 120 | } 121 | this.dapp = dapp; 122 | } 123 | return this.dapp; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/dialect-thread-notification-sink.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NotificationSink, 3 | NotificationSinkMetadata, 4 | SubscriberRepository, 5 | } from './ports'; 6 | import { Notification, ResourceId } from './data-model'; 7 | import { BlockchainSdk, DialectSdk } from '@dialectlabs/sdk'; 8 | import { compact } from 'lodash'; 9 | import { NotificationTypeEligibilityPredicate } from './internal/notification-type-eligibility-predicate'; 10 | 11 | export interface DialectNotification extends Notification { 12 | message: string; 13 | } 14 | 15 | export class DialectThreadNotificationSink 16 | implements NotificationSink 17 | { 18 | constructor( 19 | private readonly sdk: DialectSdk, 20 | private readonly subscriberRepository: SubscriberRepository, 21 | private readonly notificationTypeEligibilityPredicate: NotificationTypeEligibilityPredicate, 22 | ) {} 23 | 24 | async push( 25 | { message }: DialectNotification, 26 | recipients: ResourceId[], 27 | { notificationMetadata }: NotificationSinkMetadata, 28 | ) { 29 | const subscribers = await this.subscriberRepository.findAll(recipients); 30 | const wallets = compact( 31 | subscribers 32 | .filter((it) => 33 | this.notificationTypeEligibilityPredicate.isEligible( 34 | it, 35 | notificationMetadata, 36 | ), 37 | ) 38 | .map((it) => it.wallet), 39 | ); 40 | const results = await Promise.allSettled( 41 | wallets.map(async (it) => { 42 | const thread = await this.sdk.threads.find({ 43 | otherMembers: [it.toBase58()], 44 | }); 45 | if (!thread) { 46 | throw new Error( 47 | `Cannot send notification for subscriber ${it}, thread does not exist`, 48 | ); 49 | } 50 | return thread.send({ text: message }); 51 | }), 52 | ); 53 | const failedSends = results 54 | .filter((it) => it.status === 'rejected') 55 | .map((it) => it as PromiseRejectedResult); 56 | if (failedSends.length > 0) { 57 | console.log( 58 | `Failed to send dialect notification to ${ 59 | failedSends.length 60 | } recipients, reasons: 61 | ${failedSends.map((it) => it.reason)} 62 | `, 63 | ); 64 | } 65 | return; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './data-model'; 2 | export * from './monitor-api'; 3 | export * from './monitor-builder'; 4 | export * from './monitor-factory'; 5 | export * from './ports'; 6 | export * from './transformation-pipeline-operators'; 7 | export * from './transformation-pipelines'; 8 | export * from './sengrid-email-notification-sink'; 9 | export * from './telegram-notification-sink'; 10 | export * from './dialect-thread-notification-sink'; 11 | export * from './dialect-sdk-notification-sink'; 12 | export * from './twilio-sms-notification-sink'; 13 | export * from './dialect-sdk-subscriber.repository'; 14 | -------------------------------------------------------------------------------- /src/internal/default-monitor-factory.ts: -------------------------------------------------------------------------------- 1 | import { Duration } from 'luxon'; 2 | import { 3 | catchError, 4 | exhaustMap, 5 | from, 6 | mergeMap, 7 | Observable, 8 | throwError, 9 | TimeoutError, 10 | timer, 11 | } from 'rxjs'; 12 | import { MonitorFactory } from '../monitor-factory'; 13 | import { 14 | DataSource, 15 | DataSourceTransformationPipeline, 16 | PollableDataSource, 17 | PushyDataSource, 18 | SubscriberRepository, 19 | } from '../ports'; 20 | import { Monitor } from '../monitor-api'; 21 | import { ResourceId, SourceData, SubscriberEvent } from '../data-model'; 22 | import { timeout } from 'rxjs/operators'; 23 | import { DefaultMonitor } from './default-monitor'; 24 | 25 | export class DefaultMonitorFactory implements MonitorFactory { 26 | private readonly shutdownHooks: (() => Promise)[] = []; 27 | 28 | constructor(private readonly subscriberRepository: SubscriberRepository) {} 29 | 30 | async shutdown() { 31 | return Promise.all(this.shutdownHooks.map((it) => it())); 32 | } 33 | 34 | createDefaultMonitor( 35 | dataSource: DataSource, 36 | datasourceTransformationPipelines: DataSourceTransformationPipeline< 37 | T, 38 | any 39 | >[], 40 | pollInterval: Duration = Duration.fromObject({ seconds: 10 }), 41 | ): Monitor { 42 | const pushyDataSource = !('subscribe' in dataSource) 43 | ? this.toPushyDataSource( 44 | dataSource as PollableDataSource, 45 | pollInterval, 46 | this.subscriberRepository, 47 | ) 48 | : dataSource; 49 | const monitor = new DefaultMonitor( 50 | pushyDataSource, 51 | datasourceTransformationPipelines, 52 | this.subscriberRepository, 53 | ); 54 | this.shutdownHooks.push(() => monitor.stop()); 55 | return monitor; 56 | } 57 | 58 | createSubscriberEventMonitor( 59 | dataSourceTransformationPipelines: DataSourceTransformationPipeline< 60 | SubscriberEvent, 61 | any 62 | >[], 63 | ): Monitor { 64 | const dataSource: PushyDataSource = new Observable< 65 | SourceData 66 | >((subscriber) => 67 | this.subscriberRepository.subscribe( 68 | ({ resourceId }) => 69 | subscriber.next({ 70 | groupingKey: resourceId.toBase58(), 71 | data: { 72 | resourceId, 73 | state: 'added', 74 | }, 75 | }), 76 | ({ resourceId }) => 77 | subscriber.next({ 78 | groupingKey: resourceId.toBase58(), 79 | data: { 80 | resourceId, 81 | state: 'removed', 82 | }, 83 | }), 84 | ), 85 | ); 86 | const monitor = new DefaultMonitor( 87 | dataSource, 88 | dataSourceTransformationPipelines, 89 | this.subscriberRepository, 90 | ); 91 | this.shutdownHooks.push(() => monitor.stop()); 92 | return monitor; 93 | } 94 | 95 | private toPushyDataSource( 96 | dataSource: PollableDataSource, 97 | pollInterval: Duration, 98 | subscriberRepository: SubscriberRepository, 99 | ): PushyDataSource { 100 | const pollTimeoutMs = Math.max( 101 | Duration.fromObject({ minutes: 10 }).toMillis(), 102 | 3 * pollInterval.toMillis(), 103 | ); 104 | const pollTimeout = Duration.fromObject({ milliseconds: pollTimeoutMs }); 105 | return timer(0, pollInterval.toMillis()).pipe( 106 | exhaustMap(() => 107 | from( 108 | subscriberRepository 109 | .findAll() 110 | .then((s) => s.map(({ resourceId }) => resourceId)), 111 | ), 112 | ), 113 | exhaustMap((resources: ResourceId[]) => from(dataSource(resources))), 114 | timeout(pollTimeout.toMillis()), 115 | catchError((error) => { 116 | if (error instanceof TimeoutError) { 117 | return throwError( 118 | new Error( 119 | `Poll timeout of ${pollTimeout.toISO()} reached. ` + error, 120 | ), 121 | ); 122 | } 123 | return throwError(error); 124 | }), 125 | mergeMap((it) => it), 126 | ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/internal/default-monitor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | from, 3 | groupBy, 4 | GroupedObservable, 5 | mergeMap, 6 | Subscription as RxJsSubscription, 7 | } from 'rxjs'; 8 | import { 9 | DataSourceTransformationPipeline, 10 | PushyDataSource, 11 | SubscriberRepository, 12 | } from '../ports'; 13 | import { Monitor } from '../monitor-api'; 14 | import { Data } from '../data-model'; 15 | import { Operators } from '../transformation-pipeline-operators'; 16 | 17 | export class DefaultMonitor implements Monitor { 18 | private started = false; 19 | 20 | private subscriptions: RxJsSubscription[] = []; 21 | 22 | constructor( 23 | private readonly dataSource: PushyDataSource, 24 | private readonly dataSourceTransformationPipelines: DataSourceTransformationPipeline< 25 | T, 26 | any 27 | >[], 28 | private readonly subscriberRepository: SubscriberRepository, 29 | ) {} 30 | 31 | async start() { 32 | if (this.started) { 33 | console.log('Already started'); 34 | return; 35 | } 36 | this.startMonitorPipeline(); 37 | this.started = true; 38 | } 39 | 40 | stop(): Promise { 41 | if (!this.started) { 42 | return Promise.resolve(); 43 | } 44 | this.subscriptions.forEach((it) => it.unsubscribe()); 45 | this.subscriptions = []; 46 | this.started = false; 47 | return Promise.resolve(); 48 | } 49 | 50 | private async startMonitorPipeline() { 51 | const monitorPipelineSubscription = this.dataSource 52 | .pipe( 53 | mergeMap(({ data, groupingKey }) => 54 | from(this.enrichWithContext(data, groupingKey)), 55 | ), 56 | groupBy, string, Data>( 57 | ({ context: { groupingKey } }) => groupingKey, 58 | { 59 | element: (it) => it, 60 | }, 61 | ), 62 | mergeMap((data: GroupedObservable>) => 63 | this.dataSourceTransformationPipelines.map((pipeline) => 64 | pipeline(data.pipe()), 65 | ), 66 | ), 67 | mergeMap((it) => it), 68 | ) 69 | .pipe(...Operators.FlowControl.onErrorRetry()) 70 | .subscribe(); 71 | this.subscriptions.push(monitorPipelineSubscription); 72 | } 73 | 74 | private async enrichWithContext( 75 | origin: T, 76 | groupingKey: string, 77 | ): Promise> { 78 | const subscribers = await this.subscriberRepository.findAll(); 79 | return { 80 | context: { 81 | origin, 82 | groupingKey, 83 | subscribers, 84 | trace: [], 85 | }, 86 | value: origin, 87 | }; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/internal/in-memory-subscriber.repository.ts: -------------------------------------------------------------------------------- 1 | import { ResourceId } from '../data-model'; 2 | import { 3 | Subscriber, 4 | SubscriberEventHandler, 5 | SubscriberRepository, 6 | } from '../ports'; 7 | import { Duration } from 'luxon'; 8 | 9 | export class InMemorySubscriberRepository implements SubscriberRepository { 10 | private readonly subscribers: Map = new Map< 11 | String, 12 | Subscriber 13 | >(); 14 | 15 | private readonly onSubscriberAddedHandlers: SubscriberEventHandler[] = []; 16 | private readonly onSubscriberRemovedHandlers: SubscriberEventHandler[] = []; 17 | 18 | private isInitialized = false; 19 | 20 | constructor( 21 | private readonly delegate: SubscriberRepository, 22 | private readonly cacheTtl: Duration, 23 | ) {} 24 | 25 | static decorate(other: SubscriberRepository, cacheTtl: Duration) { 26 | return new InMemorySubscriberRepository(other, cacheTtl); 27 | } 28 | 29 | async findAll(resourceIds?: ResourceId[]): Promise { 30 | await this.lazyInit(); 31 | const subscribers = Array(...this.subscribers.values()); 32 | return resourceIds 33 | ? subscribers.filter(({ resourceId }) => 34 | resourceIds.find((it) => it.equals(resourceId)), 35 | ) 36 | : subscribers; 37 | } 38 | 39 | private async lazyInit() { 40 | if (this.isInitialized) { 41 | return; 42 | } 43 | await this.initialize(); 44 | this.isInitialized = true; 45 | } 46 | 47 | async subscribe( 48 | onSubscriberAdded?: SubscriberEventHandler, 49 | onSubscriberRemoved?: SubscriberEventHandler, 50 | ) { 51 | await this.lazyInit(); 52 | onSubscriberAdded && this.onSubscriberAddedHandlers.push(onSubscriberAdded); 53 | onSubscriberRemoved && 54 | this.onSubscriberRemovedHandlers.push(onSubscriberRemoved); 55 | } 56 | 57 | private async initialize() { 58 | setInterval(async () => { 59 | try { 60 | await this.updateSubscribers(); 61 | } catch (e) { 62 | console.error('Updating subscribers failed.', e); 63 | } 64 | }, this.cacheTtl.toMillis()); 65 | return this.updateSubscribers(); 66 | } 67 | 68 | private async updateSubscribers() { 69 | const subscribers = await this.delegate.findAll(); 70 | const added = subscribers.filter( 71 | (it) => !this.subscribers.has(it.resourceId.toBase58()), 72 | ); 73 | subscribers.forEach((subscriber) => { 74 | this.subscribers.set(subscriber.resourceId.toBase58(), subscriber); 75 | }); 76 | if (added.length > 0) { 77 | console.log( 78 | `${added.length} subscriber(s) added: [${added.map( 79 | (it) => it.resourceId, 80 | )}]`, 81 | ); 82 | console.debug( 83 | `${added.length} subscriber(s) added: ${JSON.stringify(added)}`, 84 | ); 85 | } 86 | added.forEach((subscriber) => { 87 | this.onSubscriberAddedHandlers.forEach((it) => it(subscriber)); 88 | this.subscribers.set(subscriber.resourceId.toBase58(), subscriber); 89 | }); 90 | const removed = Array.from(this.subscribers.values()).filter( 91 | (s1) => !subscribers.find((s2) => s2.resourceId.equals(s1.resourceId)), 92 | ); 93 | if (removed.length > 0) { 94 | console.log( 95 | `${removed.length} subscriber(s) removed: [${removed.map( 96 | (it) => it.resourceId, 97 | )}]`, 98 | ); 99 | console.debug( 100 | `${removed.length} subscriber(s) removed: ${JSON.stringify(removed)}`, 101 | ); 102 | } 103 | removed.forEach((subscriber) => { 104 | this.onSubscriberRemovedHandlers.forEach((it) => it(subscriber)); 105 | this.subscribers.delete(subscriber.resourceId.toBase58()); 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/internal/monitor-builder.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AddSinksStep, 3 | AddTransformationsStep, 4 | BuildStep, 5 | ChooseDataSourceStep, 6 | DefineDataSourceStep, 7 | DispatchStrategy, 8 | KeysMatching, 9 | NotificationMetadata, 10 | NotifyStep, 11 | Transformation, 12 | } from '../monitor-builder'; 13 | import { Data, Notification, SubscriberEvent } from '../data-model'; 14 | import { 15 | ContextEnrichedPushyDataSource, 16 | DataSourceTransformationPipeline, 17 | NotificationSink, 18 | PollableDataSource, 19 | PushyDataSource, 20 | SubscriberRepository, 21 | } from '../ports'; 22 | import { Duration } from 'luxon'; 23 | import { exhaustMap, from, Observable } from 'rxjs'; 24 | import { map } from 'rxjs/operators'; 25 | import { Monitor, MonitorProps, Monitors } from '../monitor-api'; 26 | import { 27 | DialectNotification, 28 | DialectThreadNotificationSink, 29 | } from '../dialect-thread-notification-sink'; 30 | import { 31 | EmailNotification, 32 | SengridEmailNotificationSink, 33 | } from '../sengrid-email-notification-sink'; 34 | import { 35 | SmsNotification, 36 | TwilioSmsNotificationSink, 37 | } from '../twilio-sms-notification-sink'; 38 | import { 39 | TelegramNotification, 40 | TelegramNotificationSink, 41 | } from '../telegram-notification-sink'; 42 | import { 43 | SolflareNotification, 44 | SolflareNotificationSink, 45 | } from '../solflare-notification-sink'; 46 | import { 47 | DialectSdkNotification, 48 | DialectSdkNotificationSink, 49 | } from '../dialect-sdk-notification-sink'; 50 | import { SubscriberRepositoryFactory } from './subscriber-repository-factory'; 51 | import { NotificationTypeEligibilityPredicate } from './notification-type-eligibility-predicate'; 52 | 53 | /** 54 | * A set of factory methods to create monitors 55 | */ 56 | export class MonitorsBuilderState { 57 | private readonly notificationTypeEligibilityPredicate: NotificationTypeEligibilityPredicate = 58 | NotificationTypeEligibilityPredicate.create(); 59 | 60 | chooseDataSourceStep?: ChooseDataSourceStepImpl; 61 | defineDataSourceStep?: DefineDataSourceStepImpl; 62 | addTransformationsStep?: AddTransformationsStepImpl; 63 | 64 | dialectNotificationSink?: DialectThreadNotificationSink; 65 | dialectSdkNotificationSink?: DialectSdkNotificationSink; 66 | emailNotificationSink?: SengridEmailNotificationSink; 67 | smsNotificationSink?: TwilioSmsNotificationSink; 68 | telegramNotificationSink?: TelegramNotificationSink; 69 | solflareNotificationSink?: SolflareNotificationSink; 70 | 71 | static create(monitorProps: MonitorProps) { 72 | const subscriberRepositoryFactory = new SubscriberRepositoryFactory( 73 | monitorProps, 74 | ); 75 | return new MonitorsBuilderState( 76 | monitorProps, 77 | subscriberRepositoryFactory.create(), 78 | ); 79 | } 80 | 81 | constructor( 82 | monitorProps: MonitorProps, 83 | readonly subscriberRepository: SubscriberRepository, 84 | ) { 85 | this.dialectNotificationSink = 86 | this.createDialectThreadNotificationSink(monitorProps); 87 | this.dialectSdkNotificationSink = 88 | this.createDialectSdkNotificationSink(monitorProps); 89 | const sinks = monitorProps?.sinks; 90 | if (sinks?.email) { 91 | this.emailNotificationSink = new SengridEmailNotificationSink( 92 | sinks.email.apiToken, 93 | sinks.email.senderEmail, 94 | this.subscriberRepository, 95 | this.notificationTypeEligibilityPredicate, 96 | ); 97 | } 98 | if (sinks?.sms) { 99 | this.smsNotificationSink = new TwilioSmsNotificationSink( 100 | { 101 | username: sinks.sms.twilioUsername, 102 | password: sinks.sms.twilioPassword, 103 | }, 104 | sinks.sms.senderSmsNumber, 105 | this.subscriberRepository, 106 | this.notificationTypeEligibilityPredicate, 107 | ); 108 | } 109 | if (sinks?.telegram) { 110 | this.telegramNotificationSink = new TelegramNotificationSink( 111 | sinks.telegram.telegramBotToken, 112 | this.subscriberRepository, 113 | this.notificationTypeEligibilityPredicate, 114 | ); 115 | } 116 | if (sinks?.solflare) { 117 | this.solflareNotificationSink = new SolflareNotificationSink( 118 | sinks.solflare.apiKey, 119 | sinks.solflare.apiUrl, 120 | ); 121 | } 122 | } 123 | 124 | private createDialectThreadNotificationSink(monitorProps: MonitorProps) { 125 | if ('sdk' in monitorProps) { 126 | return new DialectThreadNotificationSink( 127 | monitorProps.sdk, 128 | this.subscriberRepository, 129 | this.notificationTypeEligibilityPredicate, 130 | ); 131 | } else { 132 | const sdk = monitorProps.sinks?.dialect?.sdk; 133 | return ( 134 | sdk && 135 | new DialectThreadNotificationSink( 136 | sdk, 137 | this.subscriberRepository, 138 | this.notificationTypeEligibilityPredicate, 139 | ) 140 | ); 141 | } 142 | } 143 | 144 | private createDialectSdkNotificationSink(monitorProps: MonitorProps) { 145 | if ('sdk' in monitorProps) { 146 | return new DialectSdkNotificationSink( 147 | monitorProps.sdk, 148 | this.subscriberRepository, 149 | ); 150 | } else { 151 | const sdk = monitorProps.sinks?.dialect?.sdk; 152 | return ( 153 | sdk && new DialectSdkNotificationSink(sdk, this.subscriberRepository) 154 | ); 155 | } 156 | } 157 | } 158 | 159 | type DataSourceType = 'user-defined' | 'subscriber-events'; 160 | 161 | export class ChooseDataSourceStepImpl implements ChooseDataSourceStep { 162 | dataSourceType?: DataSourceType; 163 | 164 | constructor(readonly monitorProps: MonitorProps) {} 165 | 166 | subscriberEvents(): AddTransformationsStep { 167 | this.dataSourceType = 'subscriber-events'; 168 | const monitorsBuilderState = MonitorsBuilderState.create( 169 | this.monitorProps, 170 | ); 171 | monitorsBuilderState.chooseDataSourceStep = this; 172 | return new AddTransformationsStepImpl( 173 | monitorsBuilderState, 174 | ); 175 | } 176 | 177 | defineDataSource(): DefineDataSourceStep { 178 | this.dataSourceType = 'user-defined'; 179 | const monitorsBuilderState = MonitorsBuilderState.create( 180 | this.monitorProps, 181 | ); 182 | monitorsBuilderState.chooseDataSourceStep = this; 183 | return new DefineDataSourceStepImpl(monitorsBuilderState); 184 | } 185 | } 186 | 187 | type DataSourceStrategy = 'push' | 'poll'; 188 | 189 | export class DefineDataSourceStepImpl 190 | implements DefineDataSourceStep 191 | { 192 | dataSourceStrategy?: DataSourceStrategy; 193 | pushyDataSource?: PushyDataSource; 194 | pollableDataSource?: PollableDataSource; 195 | pollInterval?: Duration; 196 | 197 | constructor(private readonly monitorBuilderState: MonitorsBuilderState) { 198 | this.monitorBuilderState.defineDataSourceStep = this; 199 | } 200 | 201 | poll( 202 | dataSource: PollableDataSource, 203 | pollInterval: Duration, 204 | ): AddTransformationsStep { 205 | this.pollableDataSource = dataSource; 206 | this.pollInterval = pollInterval; 207 | this.dataSourceStrategy = 'poll'; 208 | return new AddTransformationsStepImpl(this.monitorBuilderState); 209 | } 210 | 211 | push(dataSource: PushyDataSource): AddTransformationsStep { 212 | this.pushyDataSource = dataSource; 213 | this.dataSourceStrategy = 'push'; 214 | return new AddTransformationsStepImpl(this.monitorBuilderState); 215 | } 216 | } 217 | 218 | class AddTransformationsStepImpl 219 | implements AddTransformationsStep 220 | { 221 | dataSourceTransformationPipelines: DataSourceTransformationPipeline< 222 | T, 223 | any 224 | >[] = []; 225 | 226 | constructor(readonly monitorBuilderState: MonitorsBuilderState) { 227 | monitorBuilderState.addTransformationsStep = this; 228 | } 229 | 230 | notify(metadata?: NotificationMetadata): AddSinksStep { 231 | const identityTransformation: DataSourceTransformationPipeline< 232 | T, 233 | Data 234 | > = (dataSource) => dataSource; 235 | this.dataSourceTransformationPipelines.push(identityTransformation); 236 | return new AddSinksStepImpl( 237 | this, 238 | this.dataSourceTransformationPipelines, 239 | metadata, 240 | this.monitorBuilderState.dialectNotificationSink, 241 | this.monitorBuilderState.dialectSdkNotificationSink, 242 | this.monitorBuilderState.emailNotificationSink, 243 | this.monitorBuilderState.smsNotificationSink, 244 | this.monitorBuilderState.telegramNotificationSink, 245 | this.monitorBuilderState.solflareNotificationSink, 246 | ); 247 | } 248 | 249 | transform(transformation: Transformation): NotifyStep { 250 | const dataSourceTransformationPipelines: DataSourceTransformationPipeline< 251 | T, 252 | Data 253 | >[] = []; 254 | 255 | const { keys, pipelines } = transformation; 256 | const adaptedToDataSourceTypePipelines: (( 257 | dataSource: ContextEnrichedPushyDataSource, 258 | ) => Observable>)[] = keys.flatMap((key: KeysMatching) => 259 | pipelines.map( 260 | ( 261 | pipeline: (source: Observable>) => Observable>, 262 | ) => { 263 | const adaptedToDataSourceType: ( 264 | dataSource: ContextEnrichedPushyDataSource, 265 | ) => Observable> = ( 266 | dataSource: ContextEnrichedPushyDataSource, 267 | ) => 268 | pipeline( 269 | dataSource.pipe( 270 | map((it) => ({ 271 | ...it, 272 | value: it.value[key] as unknown as V, 273 | })), 274 | ), 275 | ); 276 | return adaptedToDataSourceType; 277 | }, 278 | ), 279 | ); 280 | dataSourceTransformationPipelines.push(...adaptedToDataSourceTypePipelines); 281 | return new NotifyStepImpl( 282 | this, 283 | dataSourceTransformationPipelines, 284 | this.monitorBuilderState, 285 | ); 286 | } 287 | } 288 | 289 | class NotifyStepImpl implements NotifyStep { 290 | constructor( 291 | private readonly addTransformationsStep: AddTransformationsStepImpl, 292 | private readonly dataSourceTransformationPipelines: DataSourceTransformationPipeline< 293 | T, 294 | Data 295 | >[], 296 | private readonly monitorBuilderState: MonitorsBuilderState, 297 | ) {} 298 | 299 | notify(metadata?: NotificationMetadata): AddSinksStep { 300 | return new AddSinksStepImpl( 301 | this.addTransformationsStep, 302 | this.dataSourceTransformationPipelines, 303 | metadata, 304 | this.monitorBuilderState.dialectNotificationSink, 305 | this.monitorBuilderState.dialectSdkNotificationSink, 306 | this.monitorBuilderState.emailNotificationSink, 307 | this.monitorBuilderState.smsNotificationSink, 308 | this.monitorBuilderState.telegramNotificationSink, 309 | this.monitorBuilderState.solflareNotificationSink, 310 | ); 311 | } 312 | } 313 | 314 | class AddSinksStepImpl implements AddSinksStep { 315 | private sinkWriters: ((data: Data) => Promise)[] = []; 316 | 317 | constructor( 318 | private readonly addTransformationsStep: AddTransformationsStepImpl, 319 | private readonly dataSourceTransformationPipelines: DataSourceTransformationPipeline< 320 | T, 321 | Data 322 | >[], 323 | private readonly notificationMetadata?: NotificationMetadata, 324 | private readonly dialectNotificationSink?: DialectThreadNotificationSink, 325 | private readonly dialectSdkNotificationSink?: DialectSdkNotificationSink, 326 | private readonly emailNotificationSink?: SengridEmailNotificationSink, 327 | private readonly smsNotificationSink?: TwilioSmsNotificationSink, 328 | private readonly telegramNotificationSink?: TelegramNotificationSink, 329 | private readonly solflareNotificationSink?: SolflareNotificationSink, 330 | ) {} 331 | 332 | also(): AddTransformationsStep { 333 | this.populateDataSourceTransformationPipelines(); 334 | return this.addTransformationsStep!; 335 | } 336 | 337 | dialectThread( 338 | adapter: (data: Data) => DialectNotification, 339 | dispatchStrategy: DispatchStrategy, 340 | ): AddSinksStep { 341 | if (!this.dialectNotificationSink) { 342 | throw new Error( 343 | 'Dialect notification sink must be initialized before using', 344 | ); 345 | } 346 | return this.custom(adapter, this.dialectNotificationSink, dispatchStrategy); 347 | } 348 | 349 | dialectSdk( 350 | adapter: (data: Data) => DialectSdkNotification, 351 | dispatchStrategy: DispatchStrategy, 352 | ): AddSinksStep { 353 | if (!this.dialectSdkNotificationSink) { 354 | throw new Error( 355 | 'Dialect Cloud notification sink must be initialized before using', 356 | ); 357 | } 358 | return this.custom( 359 | adapter, 360 | this.dialectSdkNotificationSink, 361 | dispatchStrategy, 362 | ); 363 | } 364 | 365 | custom( 366 | adapter: (data: Data) => N, 367 | sink: NotificationSink, 368 | dispatchStrategy: DispatchStrategy, 369 | ) { 370 | const sinkWriter: (data: Data) => Promise = (data) => { 371 | const toBeNotified = this.selectResources(dispatchStrategy, data); 372 | return sink!.push(adapter(data), toBeNotified, { 373 | dispatchType: dispatchStrategy.dispatch, 374 | notificationMetadata: this.notificationMetadata, 375 | }); 376 | }; 377 | this.sinkWriters.push(sinkWriter); 378 | return this; 379 | } 380 | 381 | email( 382 | adapter: (data: Data) => EmailNotification, 383 | dispatchStrategy: DispatchStrategy, 384 | ): AddSinksStep { 385 | if (!this.emailNotificationSink) { 386 | throw new Error( 387 | 'Email notification sink must be initialized before using', 388 | ); 389 | } 390 | return this.custom(adapter, this.emailNotificationSink, dispatchStrategy); 391 | } 392 | 393 | sms( 394 | adapter: (data: Data) => SmsNotification, 395 | dispatchStrategy: DispatchStrategy, 396 | ): AddSinksStep { 397 | if (!this.smsNotificationSink) { 398 | throw new Error('SMS notification sink must be initialized before using'); 399 | } 400 | return this.custom(adapter, this.smsNotificationSink, dispatchStrategy); 401 | } 402 | 403 | telegram( 404 | adapter: (data: Data) => TelegramNotification, 405 | dispatchStrategy: DispatchStrategy, 406 | ): AddSinksStep { 407 | if (!this.telegramNotificationSink) { 408 | throw new Error( 409 | 'Telegram notification sink must be initialized before using', 410 | ); 411 | } 412 | return this.custom( 413 | adapter, 414 | this.telegramNotificationSink, 415 | dispatchStrategy, 416 | ); 417 | } 418 | 419 | solflare( 420 | adapter: (data: Data) => SolflareNotification, 421 | dispatchStrategy: DispatchStrategy, 422 | ): AddSinksStep { 423 | if (!this.solflareNotificationSink) { 424 | throw new Error( 425 | 'Solflare notification sink must be initialized before using', 426 | ); 427 | } 428 | return this.custom( 429 | adapter, 430 | this.solflareNotificationSink, 431 | dispatchStrategy, 432 | ); 433 | } 434 | 435 | and(): BuildStep { 436 | this.populateDataSourceTransformationPipelines(); 437 | return new BuildStepImpl(this.addTransformationsStep!.monitorBuilderState); 438 | } 439 | 440 | private populateDataSourceTransformationPipelines() { 441 | const transformAndLoadPipelines: DataSourceTransformationPipeline< 442 | T, 443 | any 444 | >[] = this.dataSourceTransformationPipelines.map( 445 | ( 446 | dataSourceTransformationPipeline: DataSourceTransformationPipeline< 447 | T, 448 | Data 449 | >, 450 | ) => { 451 | const transformAndLoadPipeline: DataSourceTransformationPipeline< 452 | T, 453 | any 454 | > = (dataSource) => 455 | dataSourceTransformationPipeline(dataSource).pipe( 456 | exhaustMap((event) => 457 | from(Promise.all(this.sinkWriters.map((it) => it(event)))), 458 | ), 459 | ); 460 | return transformAndLoadPipeline; 461 | }, 462 | ); 463 | this.addTransformationsStep.dataSourceTransformationPipelines.push( 464 | ...transformAndLoadPipelines, 465 | ); 466 | } 467 | 468 | private selectResources( 469 | dispatchStrategy: DispatchStrategy, 470 | { context }: Data, 471 | ) { 472 | switch (dispatchStrategy.dispatch) { 473 | case 'broadcast': { 474 | return context.subscribers.map(({ resourceId }) => resourceId); 475 | } 476 | case 'unicast': { 477 | return [dispatchStrategy.to(context)]; 478 | } 479 | case 'multicast': { 480 | return dispatchStrategy.to(context); 481 | } 482 | } 483 | } 484 | } 485 | 486 | class BuildStepImpl implements BuildStep { 487 | constructor(private readonly monitorBuilderState: MonitorsBuilderState) {} 488 | 489 | build(): Monitor { 490 | const { 491 | subscriberRepository, 492 | chooseDataSourceStep, 493 | defineDataSourceStep, 494 | addTransformationsStep, 495 | } = this.monitorBuilderState; 496 | 497 | if (!chooseDataSourceStep || !addTransformationsStep) { 498 | throw new Error( 499 | 'Expected [monitorProps, chooseDataSourceStep, addTransformationsStep] to be defined', 500 | ); 501 | } 502 | switch (chooseDataSourceStep.dataSourceType) { 503 | case 'user-defined': { 504 | if (!defineDataSourceStep) { 505 | throw new Error('Expected data source to be defined'); 506 | } 507 | return this.createUserDefinedMonitor( 508 | defineDataSourceStep, 509 | addTransformationsStep, 510 | subscriberRepository, 511 | ); 512 | } 513 | case 'subscriber-events': { 514 | return this.buildSubscriberEventMonitor( 515 | addTransformationsStep, 516 | subscriberRepository, 517 | ); 518 | } 519 | default: { 520 | throw new Error( 521 | `Unexpected data source type: ${chooseDataSourceStep.dataSourceType}`, 522 | ); 523 | } 524 | } 525 | } 526 | 527 | private buildSubscriberEventMonitor( 528 | addTransformationsStep: AddTransformationsStepImpl, 529 | subscriberRepository: SubscriberRepository, 530 | ) { 531 | const { dataSourceTransformationPipelines } = addTransformationsStep; 532 | if (!dataSourceTransformationPipelines) { 533 | throw new Error( 534 | 'Expected [dataSourceTransformationPipelines] to be defined', 535 | ); 536 | } 537 | return Monitors.factory(subscriberRepository).createSubscriberEventMonitor( 538 | dataSourceTransformationPipelines as unknown as DataSourceTransformationPipeline< 539 | SubscriberEvent, 540 | any 541 | >[], 542 | ); 543 | } 544 | 545 | private createUserDefinedMonitor( 546 | defineDataSourceStep: DefineDataSourceStepImpl, 547 | addTransformationsStep: AddTransformationsStepImpl, 548 | subscriberRepository: SubscriberRepository, 549 | ) { 550 | const { dataSourceStrategy } = defineDataSourceStep; 551 | switch (dataSourceStrategy) { 552 | case 'poll': 553 | return this.createForPollable( 554 | defineDataSourceStep, 555 | addTransformationsStep, 556 | subscriberRepository, 557 | ); 558 | case 'push': 559 | return this.createForPushy( 560 | defineDataSourceStep, 561 | addTransformationsStep, 562 | subscriberRepository, 563 | ); 564 | default: 565 | throw new Error('Expected data source strategy to be defined'); 566 | } 567 | } 568 | 569 | private createForPollable( 570 | defineDataSourceStep: DefineDataSourceStepImpl, 571 | addTransformationsStep: AddTransformationsStepImpl, 572 | subscriberRepository: SubscriberRepository, 573 | ) { 574 | const { pollableDataSource, pollInterval } = defineDataSourceStep; 575 | const { dataSourceTransformationPipelines } = addTransformationsStep; 576 | if ( 577 | !pollableDataSource || 578 | !pollInterval || 579 | !dataSourceTransformationPipelines 580 | ) { 581 | throw new Error( 582 | 'Expected [pollableDataSource, pollInterval, dataSourceTransformationPipelines] to be defined', 583 | ); 584 | } 585 | return Monitors.factory(subscriberRepository).createDefaultMonitor( 586 | pollableDataSource, 587 | dataSourceTransformationPipelines, 588 | pollInterval, 589 | ); 590 | } 591 | 592 | private createForPushy( 593 | defineDataSourceStep: DefineDataSourceStepImpl, 594 | addTransformationsStep: AddTransformationsStepImpl, 595 | subscriberRepository: SubscriberRepository, 596 | ) { 597 | const { pushyDataSource } = defineDataSourceStep; 598 | const { dataSourceTransformationPipelines } = addTransformationsStep; 599 | if (!pushyDataSource || !dataSourceTransformationPipelines) { 600 | throw new Error( 601 | 'Expected [pushyDataSource, dataSourceTransformationPipelines] to be defined', 602 | ); 603 | } 604 | return Monitors.factory(subscriberRepository).createDefaultMonitor( 605 | pushyDataSource, 606 | dataSourceTransformationPipelines, 607 | Duration.fromObject({ seconds: 1 }), // TODO: make optional 608 | ); 609 | } 610 | } 611 | -------------------------------------------------------------------------------- /src/internal/notification-type-eligibility-predicate.ts: -------------------------------------------------------------------------------- 1 | import { Subscriber } from '../ports'; 2 | import { NotificationMetadata } from '../monitor-builder'; 3 | 4 | export abstract class NotificationTypeEligibilityPredicate { 5 | abstract isEligible( 6 | subscriber: Subscriber, 7 | metadata?: NotificationMetadata, 8 | ): Boolean; 9 | 10 | static create() { 11 | return new DefaultNotificationTypeEligibilityPredicate(); 12 | } 13 | } 14 | 15 | export class DefaultNotificationTypeEligibilityPredicate extends NotificationTypeEligibilityPredicate { 16 | isEligible( 17 | { notificationSubscriptions }: Subscriber, 18 | metadata?: NotificationMetadata, 19 | ): Boolean { 20 | if (!notificationSubscriptions) { 21 | return true; 22 | } 23 | if (notificationSubscriptions?.length > 0 && !metadata?.type?.id) { 24 | console.warn( 25 | `Notification type id must be explicitly set and match dapp notification types configuration. Skipping some notifications...`, 26 | ); 27 | return false; 28 | } 29 | const found = Boolean( 30 | notificationSubscriptions.find( 31 | (subscription) => 32 | subscription.notificationType.id === metadata?.type.id || 33 | subscription.notificationType.humanReadableId.toLowerCase() === 34 | metadata?.type.id?.toLowerCase(), 35 | ), 36 | ); 37 | if (!found && metadata?.type.id) { 38 | console.warn( 39 | `Unknown notification type ${ 40 | metadata.type.id 41 | }, must be one of [${notificationSubscriptions.map( 42 | (it) => it.notificationType.humanReadableId, 43 | )}]`, 44 | ); 45 | } 46 | return found; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/internal/subscriber-repository-factory.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DialectSdkMonitorProps, 3 | GenericMonitorProps, 4 | MonitorProps, 5 | } from '../monitor-api'; 6 | import { InMemorySubscriberRepository } from './in-memory-subscriber.repository'; 7 | import { DialectSdkSubscriberRepository } from '../dialect-sdk-subscriber.repository'; 8 | import { Duration } from 'luxon'; 9 | import { SubscriberRepository } from '../ports'; 10 | import { BlockchainSdk } from '@dialectlabs/sdk'; 11 | 12 | const DEFAULT_SUBSCRIBERS_CACHE_TTL = Duration.fromObject({ minutes: 1 }); 13 | 14 | export class SubscriberRepositoryFactory { 15 | constructor(private readonly monitorProps: MonitorProps) {} 16 | 17 | create() { 18 | if ('sdk' in this.monitorProps) { 19 | return this.createFromSdk(this.monitorProps); 20 | } else { 21 | return this.createFromRepository(this.monitorProps); 22 | } 23 | } 24 | 25 | private createFromRepository( 26 | monitorProps: GenericMonitorProps, 27 | ) { 28 | const { subscriberRepository } = monitorProps; 29 | return this.decorateWithInmemoryIfNeeded(subscriberRepository); 30 | } 31 | 32 | private createFromSdk(monitorProps: DialectSdkMonitorProps) { 33 | const { sdk, subscriberRepository, subscribersCacheTTL } = monitorProps; 34 | return subscriberRepository 35 | ? this.decorateWithInmemoryIfNeeded(subscriberRepository) 36 | : InMemorySubscriberRepository.decorate( 37 | new DialectSdkSubscriberRepository(sdk), 38 | subscribersCacheTTL ?? DEFAULT_SUBSCRIBERS_CACHE_TTL, 39 | ); 40 | } 41 | 42 | private decorateWithInmemoryIfNeeded( 43 | subscriberRepository: SubscriberRepository, 44 | ) { 45 | return subscriberRepository instanceof InMemorySubscriberRepository 46 | ? subscriberRepository 47 | : InMemorySubscriberRepository.decorate( 48 | subscriberRepository, 49 | this.monitorProps.subscribersCacheTTL ?? 50 | DEFAULT_SUBSCRIBERS_CACHE_TTL, 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/monitor-api.ts: -------------------------------------------------------------------------------- 1 | import { DefaultMonitorFactory } from './internal/default-monitor-factory'; 2 | 3 | import { ChooseDataSourceStep } from './monitor-builder'; 4 | import { MonitorFactory } from './monitor-factory'; 5 | import { ChooseDataSourceStepImpl } from './internal/monitor-builder'; 6 | import { BlockchainSdk, DialectSdk } from '@dialectlabs/sdk'; 7 | import { SubscriberRepository } from './ports'; 8 | import { Duration } from 'luxon'; 9 | 10 | export type MonitorProps = 11 | | GenericMonitorProps 12 | | DialectSdkMonitorProps; 13 | 14 | export interface GenericMonitorProps { 15 | subscriberRepository: SubscriberRepository; 16 | subscribersCacheTTL?: Duration; 17 | sinks?: SinksConfiguration; 18 | } 19 | 20 | export interface DialectSdkMonitorProps { 21 | sdk: DialectSdk; 22 | subscriberRepository?: SubscriberRepository; 23 | subscribersCacheTTL?: Duration; 24 | sinks?: Omit, 'dialectThread' | 'dialectSdk'>; 25 | } 26 | 27 | export interface SinksConfiguration { 28 | email?: EmailSinkConfiguration; 29 | sms?: SmsSinkConfiguration; 30 | telegram?: TelegramSinkConfiguration; 31 | dialect?: DialectSinksConfiguration; 32 | solflare?: SolflareSinkConfiguration; 33 | } 34 | 35 | export interface EmailSinkConfiguration { 36 | apiToken: string; 37 | senderEmail: string; 38 | } 39 | 40 | export interface SmsSinkConfiguration { 41 | twilioUsername: string; 42 | twilioPassword: string; 43 | senderSmsNumber: string; 44 | } 45 | 46 | export interface TelegramSinkConfiguration { 47 | telegramBotToken: string; 48 | } 49 | 50 | export interface DialectSinksConfiguration { 51 | sdk: DialectSdk; 52 | } 53 | 54 | export interface SolflareSinkConfiguration { 55 | apiKey: string; 56 | apiUrl?: string; 57 | } 58 | 59 | /** 60 | * A monitor is an entity that is responsible for execution of unbounded streaming ETL (Extract, Transform, Load) 61 | * and includes data ingestion, transformation and dispatching notifications 62 | * @typeParam T data source type 63 | 64 | */ 65 | export interface Monitor { 66 | start(): Promise; 67 | 68 | stop(): Promise; 69 | } 70 | 71 | /** 72 | * A main entry point that provides API to create new monitors either by step-by-step builder or factory 73 | */ 74 | export class Monitors { 75 | private static factoryInstance: DefaultMonitorFactory; 76 | 77 | /** 78 | * A rich builder that guides developer on monitor creation step 79 | /** 80 | * Example: 81 | * 82 | * ```typescript 83 | * Monitors.builder({ 84 | * subscriberRepository: xxx, 85 | * notificationSink: xxx, 86 | * }) 87 | * .subscriberEvents() 88 | * .transform({ 89 | * keys: ['state'], 90 | * pipelines: [ 91 | * Pipelines.sendMessageToNewSubscriber({ 92 | * title: 'Welcome title', 93 | * messageBuilder: () => `Hi! Welcome onboard :)`, 94 | * }), 95 | * ], 96 | * }) 97 | * .dispatch('unicast') 98 | * .build() 99 | * .start() 100 | * // run typedoc --help for a list of supported languages 101 | * const instance = new MyClass(); 102 | * ``` 103 | */ 104 | static builder(monitorProps: MonitorProps): ChooseDataSourceStep { 105 | return new ChooseDataSourceStepImpl(monitorProps); 106 | } 107 | 108 | /** 109 | * A more low-level way to create monitors 110 | */ 111 | static factory(subscriberRepository: SubscriberRepository): MonitorFactory { 112 | if (!Monitors.factoryInstance) { 113 | Monitors.factoryInstance = new DefaultMonitorFactory( 114 | subscriberRepository, 115 | ); 116 | } 117 | return Monitors.factoryInstance; 118 | } 119 | 120 | /** 121 | * Shutdowns monitor app and closes all related resources 122 | */ 123 | static async shutdown() { 124 | return Monitors.factoryInstance && Monitors.factoryInstance.shutdown(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/monitor-builder.ts: -------------------------------------------------------------------------------- 1 | import { Duration } from 'luxon'; 2 | import { 3 | NotificationSink, 4 | PollableDataSource, 5 | PushyDataSource, 6 | TransformationPipeline, 7 | } from './ports'; 8 | import { Monitor } from './monitor-api'; 9 | import { 10 | Context, 11 | Data, 12 | Notification, 13 | ResourceId, 14 | SubscriberEvent, 15 | } from './data-model'; 16 | import { DialectNotification } from './dialect-thread-notification-sink'; 17 | import { EmailNotification } from './sengrid-email-notification-sink'; 18 | import { SmsNotification } from './twilio-sms-notification-sink'; 19 | import { TelegramNotification } from './telegram-notification-sink'; 20 | import { SolflareNotification } from './solflare-notification-sink'; 21 | import { DialectSdkNotification } from './dialect-sdk-notification-sink'; 22 | 23 | export interface ChooseDataSourceStep { 24 | /** 25 | * Use subscriber events as a source of data 26 | * Useful when you need to e.g. send some message for new subscribers 27 | */ 28 | subscriberEvents(): AddTransformationsStep; 29 | 30 | /** 31 | * Define a new data source 32 | * Useful when you have some on-chain resources or API to get data from 33 | * @typeParam T data type to be provided by data source 34 | */ 35 | defineDataSource(): DefineDataSourceStep; 36 | } 37 | 38 | export interface DefineDataSourceStep { 39 | /** 40 | * Use poll model to supply new data from some on-chain resources or API 41 | * @param dataSource function that is polled by framework to get new data 42 | * @param pollInterval an interval of polling 43 | * @typeParam T data type to be provided {@linkcode PollableDataSource} 44 | */ 45 | poll( 46 | dataSource: PollableDataSource, 47 | pollInterval: Duration, 48 | ): AddTransformationsStep; 49 | 50 | push(dataSource: PushyDataSource): AddTransformationsStep; 51 | } 52 | 53 | export type KeysMatching = { 54 | [K in keyof T]: T[K] extends V ? K : never; 55 | }[keyof T]; 56 | 57 | /** 58 | * Defines which keys from need to be proceeded and how 59 | * @typeParam T data source type from {@linkcode DefineDataSourceStep} 60 | * @typeParam V data type of specified keys from T 61 | */ 62 | export interface Transformation { 63 | /** 64 | * A set of keys from data source type to be transformed 65 | * @typeParam V data type of specified keys from T 66 | */ 67 | keys: KeysMatching[]; 68 | /** 69 | * Streaming transformations that produce dialect web3 notifications ot be executed for each key 70 | * @typeParam V data type of specified keys from T 71 | */ 72 | pipelines: TransformationPipeline[]; 73 | } 74 | 75 | /** 76 | * Defines to which subscribers the notifications are send. 77 | * 1. Unicast sends notification to a single subscriber who owned the original data, provided in {@linkcode DefineDataSourceStep} 78 | */ 79 | export type DispatchStrategy = 80 | | BroadcastDispatchStrategy 81 | | UnicastDispatchStrategy 82 | | MulticastDispatchStrategy; 83 | 84 | export type DispatchType = 'broadcast' | 'unicast' | 'multicast'; 85 | 86 | export interface BaseDispatchStrategy { 87 | dispatch: DispatchType; 88 | } 89 | 90 | export interface BroadcastDispatchStrategy extends BaseDispatchStrategy { 91 | dispatch: 'broadcast'; 92 | } 93 | 94 | export interface UnicastDispatchStrategy 95 | extends BaseDispatchStrategy { 96 | dispatch: 'unicast'; 97 | to: (ctx: Context) => ResourceId; 98 | } 99 | 100 | export interface MulticastDispatchStrategy 101 | extends BaseDispatchStrategy { 102 | dispatch: 'multicast'; 103 | to: (ctx: Context) => ResourceId[]; 104 | } 105 | 106 | export interface AddTransformationsStep { 107 | transform(transformation: Transformation): NotifyStep; 108 | 109 | notify(metadata?: NotificationMetadata): AddSinksStep; 110 | } 111 | 112 | export interface NotificationMetadata { 113 | type: NotificationTypeMetadata; 114 | } 115 | 116 | export interface NotificationTypeMetadata { 117 | id: string; 118 | } 119 | 120 | export interface NotifyStep { 121 | /** 122 | * Finish adding transformations and configure how to dispatch notifications 123 | */ 124 | notify(metadata?: NotificationMetadata): AddSinksStep; 125 | } 126 | 127 | export interface AddSinksStep { 128 | dialectThread( 129 | adapter: (data: Data) => DialectNotification, 130 | dispatchStrategy: DispatchStrategy, 131 | ): AddSinksStep; 132 | 133 | dialectSdk( 134 | adapter: (data: Data) => DialectSdkNotification, 135 | dispatchStrategy: DispatchStrategy, 136 | ): AddSinksStep; 137 | 138 | email( 139 | adapter: (data: Data) => EmailNotification, 140 | dispatchStrategy: DispatchStrategy, 141 | ): AddSinksStep; 142 | 143 | sms( 144 | adapter: (data: Data) => SmsNotification, 145 | dispatchStrategy: DispatchStrategy, 146 | ): AddSinksStep; 147 | 148 | telegram( 149 | adapter: (data: Data) => TelegramNotification, 150 | dispatchStrategy: DispatchStrategy, 151 | ): AddSinksStep; 152 | 153 | solflare( 154 | adapter: (data: Data) => SolflareNotification, 155 | dispatchStrategy: DispatchStrategy, 156 | ): AddSinksStep; 157 | 158 | custom( 159 | adapter: (data: Data) => N, 160 | sink: NotificationSink, 161 | dispatchStrategy: DispatchStrategy, 162 | ): AddSinksStep; 163 | 164 | also(): AddTransformationsStep; 165 | 166 | and(): BuildStep; 167 | } 168 | 169 | export interface BuildStep { 170 | /** 171 | * Creates new monitor based on configuration above 172 | */ 173 | build(): Monitor; 174 | } 175 | -------------------------------------------------------------------------------- /src/monitor-factory.ts: -------------------------------------------------------------------------------- 1 | import { Duration } from 'luxon'; 2 | import { DataSource, DataSourceTransformationPipeline } from './ports'; 3 | import { Monitor } from './monitor-api'; 4 | import { SubscriberEvent } from './data-model'; 5 | 6 | export interface MonitorFactory { 7 | createDefaultMonitor( 8 | dataSource: DataSource, 9 | transformationPipelines: DataSourceTransformationPipeline[], 10 | pollInterval: Duration, 11 | ): Monitor; 12 | 13 | createSubscriberEventMonitor( 14 | eventDetectionPipelines: DataSourceTransformationPipeline< 15 | SubscriberEvent, 16 | any 17 | >[], 18 | ): Monitor; 19 | } 20 | -------------------------------------------------------------------------------- /src/ports.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { Data, Notification, ResourceId, SourceData } from './data-model'; 3 | import { PublicKey } from '@solana/web3.js'; 4 | import { DispatchType, NotificationMetadata } from './monitor-builder'; 5 | 6 | /** 7 | * An abstraction that represents a source of data, bound to specific type 8 | */ 9 | export type DataSource = 10 | | PushyDataSource 11 | | PollableDataSource; 12 | 13 | /** 14 | * Pollable data source is polled by framework to get new data 15 | */ 16 | export interface PollableDataSource { 17 | (subscribers: ResourceId[]): Promise[]>; 18 | } 19 | 20 | /** 21 | * Pushy data source delivers data asynchronously, which eliminates polling 22 | */ 23 | export type PushyDataSource = Observable>; 24 | export type ContextEnrichedPushyDataSource = Observable< 25 | Data 26 | >; 27 | 28 | /** 29 | * A set of transformations that are executed on-top of unbound pushy data source 30 | * to generate a new notification 31 | */ 32 | export type DataSourceTransformationPipeline = ( 33 | dataSource: ContextEnrichedPushyDataSource, 34 | ) => Observable; 35 | 36 | /** 37 | * A set of transformations that are executed on-top of a specific key from unbound pushy data source 38 | * to generate notification but bound to a 39 | */ 40 | export type TransformationPipeline = ( 41 | upstream: Observable>, 42 | ) => Observable>; 43 | 44 | export type SubscriberEventHandler = (subscriber: Subscriber) => any; 45 | 46 | /** 47 | * Repository containing all subscribers, also provides subscribe semantics to get updates 48 | */ 49 | export interface SubscriberRepository { 50 | /** 51 | * Return all subscribers of the monitor 52 | */ 53 | findAll(resourceIds?: ResourceId[]): Promise; 54 | 55 | /** 56 | * Can be used to set handlers to react if set of subscribers is changed 57 | */ 58 | subscribe( 59 | onSubscriberAdded?: SubscriberEventHandler, 60 | onSubscriberRemoved?: SubscriberEventHandler, 61 | ): any; 62 | } 63 | 64 | export interface Subscriber { 65 | resourceId: ResourceId; 66 | email?: string | null; 67 | telegramChatId?: string | null; 68 | phoneNumber?: string | null; 69 | wallet?: PublicKey | null; 70 | notificationSubscriptions?: SubscriberNotificationSubscription[]; 71 | } 72 | 73 | export interface SubscriberNotificationSubscription { 74 | notificationType: NotificationType; 75 | config: SubscriptionConfig; 76 | } 77 | 78 | export interface NotificationType { 79 | id: string; 80 | humanReadableId: string; 81 | } 82 | 83 | export interface SubscriptionConfig { 84 | enabled: boolean; 85 | } 86 | 87 | export interface NotificationSinkMetadata { 88 | dispatchType: DispatchType; 89 | notificationMetadata?: NotificationMetadata; 90 | } 91 | 92 | /** 93 | * An interface that abstracts the destination where events are sent/persisted 94 | */ 95 | export interface NotificationSink { 96 | push( 97 | notification: N, 98 | recipients: ResourceId[], 99 | metadata: NotificationSinkMetadata, 100 | ): Promise; 101 | } 102 | -------------------------------------------------------------------------------- /src/sengrid-email-notification-sink.ts: -------------------------------------------------------------------------------- 1 | import { Notification, ResourceId } from './data-model'; 2 | import { 3 | NotificationSink, 4 | NotificationSinkMetadata, 5 | SubscriberRepository, 6 | } from './ports'; 7 | import sgMail from '@sendgrid/mail'; 8 | import { MailDataRequired } from '@sendgrid/helpers/classes/mail'; 9 | import { NotificationTypeEligibilityPredicate } from './internal/notification-type-eligibility-predicate'; 10 | 11 | /** 12 | * Email notification 13 | */ 14 | export interface EmailNotification extends Notification { 15 | subject: string; 16 | text: string; 17 | } 18 | 19 | export class SengridEmailNotificationSink 20 | implements NotificationSink 21 | { 22 | constructor( 23 | private readonly sengridApiKey: string, 24 | private readonly senderEmail: string, 25 | private readonly subscriberRepository: SubscriberRepository, 26 | private readonly notificationTypeEligibilityPredicate: NotificationTypeEligibilityPredicate, 27 | ) { 28 | sgMail.setApiKey(sengridApiKey); 29 | } 30 | 31 | async push( 32 | notification: EmailNotification, 33 | recipients: ResourceId[], 34 | { notificationMetadata }: NotificationSinkMetadata, 35 | ) { 36 | const recipientEmails = await this.subscriberRepository.findAll(recipients); 37 | console.log('sendgrid-notif-sink, recipients:\n'); 38 | console.log(recipientEmails); 39 | const emails: MailDataRequired[] = recipientEmails 40 | .filter(({ email }) => Boolean(email)) 41 | .filter((it) => 42 | this.notificationTypeEligibilityPredicate.isEligible( 43 | it, 44 | notificationMetadata, 45 | ), 46 | ) 47 | .map(({ email }) => ({ 48 | ...notification, 49 | from: this.senderEmail, 50 | to: email!, 51 | })); 52 | 53 | const results = await Promise.allSettled(await sgMail.send(emails)); 54 | 55 | const failedSends = results 56 | .filter((it) => it.status === 'rejected') 57 | .map((it) => it as PromiseRejectedResult); 58 | if (failedSends.length > 0) { 59 | console.log( 60 | `Failed to send dialect email notification to ${ 61 | failedSends.length 62 | } recipient(s), reasons: 63 | ${failedSends.map((it) => it.reason)} 64 | `, 65 | ); 66 | } 67 | 68 | return; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/solflare-notification-sink.ts: -------------------------------------------------------------------------------- 1 | import { Notification, ResourceId } from './data-model'; 2 | import { NotificationSink } from './ports'; 3 | import axios from 'axios'; 4 | 5 | /** 6 | * Solflare notification 7 | */ 8 | export interface SolflareNotification extends Notification { 9 | title: string; 10 | body: string; 11 | actionUrl: string | null; 12 | } 13 | 14 | export class SolflareNotificationSink 15 | implements NotificationSink 16 | { 17 | constructor( 18 | private readonly solcastApiKey: string, 19 | private readonly solcastEndpoint: string = 'https://api.solana.cloud/v1', 20 | ) { 21 | console.log( 22 | `solflare-notif-sink init, solcast endpoint: ${this.solcastEndpoint}`, 23 | ); 24 | } 25 | 26 | async push( 27 | { title, body, actionUrl }: SolflareNotification, 28 | publicKeys: ResourceId[], 29 | ) { 30 | console.log(`solflare-notif-sink, recipient:\n`); 31 | console.log(publicKeys); 32 | 33 | const results = await Promise.allSettled( 34 | publicKeys.map((publicKey) => 35 | axios.post( 36 | `${this.solcastEndpoint}/casts/unicast`, 37 | { 38 | title, 39 | body, 40 | icon: null, 41 | image: null, 42 | publicKey: publicKey.toBase58(), 43 | platform: 'all', 44 | topic: 'transactional', 45 | actionUrl, 46 | }, 47 | { 48 | headers: { Authorization: `Basic ${this.solcastApiKey}` }, 49 | }, 50 | ), 51 | ), 52 | ); 53 | 54 | const failedSends = results 55 | .filter((it) => it.status === 'rejected') 56 | .map((it) => it as PromiseRejectedResult); 57 | 58 | if (failedSends.length > 0) { 59 | console.log( 60 | `Failed to send dialect solflare notification to ${ 61 | failedSends.length 62 | } recipient(s), reasons: 63 | ${failedSends.map((it) => it.reason)} 64 | `, 65 | ); 66 | } 67 | return; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/telegram-notification-sink.ts: -------------------------------------------------------------------------------- 1 | import { Notification, ResourceId } from './data-model'; 2 | import { 3 | NotificationSink, 4 | NotificationSinkMetadata, 5 | SubscriberRepository, 6 | } from './ports'; 7 | import { Telegraf } from 'telegraf'; 8 | import { NotificationTypeEligibilityPredicate } from './internal/notification-type-eligibility-predicate'; 9 | 10 | /** 11 | * Telegram notification 12 | */ 13 | export interface TelegramNotification extends Notification { 14 | body: string; 15 | } 16 | 17 | export class TelegramNotificationSink 18 | implements NotificationSink 19 | { 20 | private bot: Telegraf; 21 | 22 | constructor( 23 | private readonly telegramBotToken: string, 24 | private readonly subscriberRepository: SubscriberRepository, 25 | private readonly notificationTypeEligibilityPredicate: NotificationTypeEligibilityPredicate, 26 | ) { 27 | this.bot = new Telegraf(telegramBotToken); 28 | } 29 | 30 | async push( 31 | notification: TelegramNotification, 32 | recipients: ResourceId[], 33 | { notificationMetadata }: NotificationSinkMetadata, 34 | ) { 35 | const recipientTelegramNumbers = await this.subscriberRepository.findAll( 36 | recipients, 37 | ); 38 | console.log('tg-notif-sink, recipients:\n'); 39 | console.log(recipientTelegramNumbers); 40 | const results = await Promise.allSettled( 41 | recipientTelegramNumbers 42 | .filter(({ telegramChatId }) => telegramChatId) 43 | .filter((it) => 44 | this.notificationTypeEligibilityPredicate.isEligible( 45 | it, 46 | notificationMetadata, 47 | ), 48 | ) 49 | .map(({ telegramChatId }) => { 50 | this.bot.telegram 51 | .sendMessage(telegramChatId!, notification.body) 52 | .then(() => {}); 53 | }), 54 | ); 55 | 56 | const failedSends = results 57 | .filter((it) => it.status === 'rejected') 58 | .map((it) => it as PromiseRejectedResult); 59 | if (failedSends.length > 0) { 60 | console.log( 61 | `Failed to send dialect notification to ${ 62 | failedSends.length 63 | } recipient(s), reasons: 64 | ${failedSends.map((it) => it.reason)} 65 | `, 66 | ); 67 | } 68 | 69 | return; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/transformation-pipeline-operators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | bufferCount, 3 | catchError, 4 | concatMap, 5 | filter, 6 | mergeMap, 7 | MonoTypeOperatorFunction, 8 | Observable, 9 | OperatorFunction, 10 | retry, 11 | scan, 12 | throttleTime, 13 | throwError, 14 | timer, 15 | toArray, 16 | windowTime, 17 | } from 'rxjs'; 18 | import { map, tap } from 'rxjs/operators'; 19 | import { Duration } from 'luxon'; 20 | import { Data } from './data-model'; 21 | 22 | export enum PipeLogLevel { 23 | TRACE, 24 | DEBUG, 25 | INFO, 26 | ERROR, 27 | } 28 | 29 | let pipeLogLevel = PipeLogLevel.INFO; 30 | 31 | export function setPipeLogLevel(level: PipeLogLevel) { 32 | pipeLogLevel = level; 33 | } 34 | 35 | export class Operators { 36 | static Transform = class { 37 | static identity(): OperatorFunction< 38 | Data, 39 | Data 40 | > { 41 | return map((data) => data); 42 | } 43 | 44 | static filter(predicate: (data: T) => boolean): OperatorFunction { 45 | return filter(predicate); 46 | } 47 | 48 | static map(mapper: (data: T) => R): OperatorFunction { 49 | return map(mapper); 50 | } 51 | }; 52 | 53 | static Window = class { 54 | static fixedSize( 55 | size: number, 56 | ): OperatorFunction, Data[]> { 57 | return bufferCount(size); 58 | } 59 | 60 | static fixedSizeSliding( 61 | size: number, 62 | ): OperatorFunction, Data[]> { 63 | return scan, Data[]>( 64 | (values, value) => values.slice(1 - size).concat(value), 65 | [], 66 | ); 67 | } 68 | 69 | static fixedTime( 70 | timeSpan: Duration, 71 | ): [ 72 | OperatorFunction, Observable>>, 73 | OperatorFunction>, Data[]>, 74 | ] { 75 | return [ 76 | windowTime>(timeSpan.toMillis()), 77 | concatMap((value) => value.pipe(toArray())), 78 | ]; 79 | } 80 | }; 81 | 82 | static Aggregate = class { 83 | static avg(): OperatorFunction< 84 | Data[], 85 | Data 86 | > { 87 | return map((values) => { 88 | const acc = values.reduce((acc, next) => ({ 89 | value: acc.value + next.value, 90 | context: next.context, 91 | })); 92 | return { 93 | ...acc, 94 | value: acc.value / values.length, 95 | }; 96 | }); 97 | } 98 | 99 | static max(): OperatorFunction< 100 | Data[], 101 | Data 102 | > { 103 | return map((values) => 104 | values.reduce((acc, next) => (acc.value > next.value ? acc : next)), 105 | ); 106 | } 107 | 108 | static min(): OperatorFunction< 109 | Data[], 110 | Data 111 | > { 112 | return map((values) => 113 | values.reduce((acc, next) => (acc.value < next.value ? acc : next)), 114 | ); 115 | } 116 | }; 117 | 118 | static Trigger = class { 119 | static risingEdge( 120 | threshold: number, 121 | limit?: number, 122 | ): [ 123 | OperatorFunction, Data[]>, 124 | OperatorFunction[], Data[]>, 125 | OperatorFunction[], Data>, 126 | ] { 127 | return [ 128 | Operators.Window.fixedSizeSliding(2), 129 | filter( 130 | (it) => 131 | it.length === 2 && 132 | it[0].value <= threshold && 133 | threshold < it[1].value && 134 | (!limit || it[1].value < limit), 135 | ), 136 | map(([_, snd]) => snd), 137 | ]; 138 | } 139 | 140 | static fallingEdge( 141 | threshold: number, 142 | limit?: number, 143 | ): [ 144 | OperatorFunction, Data[]>, 145 | OperatorFunction[], Data[]>, 146 | OperatorFunction[], Data>, 147 | ] { 148 | return [ 149 | Operators.Window.fixedSizeSliding(2), 150 | filter( 151 | (data) => 152 | data.length === 2 && 153 | data[0].value >= threshold && 154 | threshold > data[1].value && 155 | (!limit || data[1].value > limit), 156 | ), 157 | map(([_, snd]) => snd), 158 | ]; 159 | } 160 | 161 | static increase( 162 | threshold: number, 163 | ): [ 164 | OperatorFunction, Data[]>, 165 | OperatorFunction[], Data[]>, 166 | OperatorFunction[], Data>, 167 | ] { 168 | return [ 169 | Operators.Window.fixedSizeSliding(2), 170 | filter( 171 | (data) => 172 | data.length === 2 && data[1].value - data[0].value >= threshold, 173 | ), 174 | map(([fst, snd]) => ({ 175 | ...snd, 176 | context: { 177 | ...snd.context, 178 | trace: [ 179 | ...snd.context.trace, 180 | { 181 | type: 'trigger', 182 | input: [fst.value, snd.value], 183 | output: snd.value - fst.value, 184 | }, 185 | ], 186 | }, 187 | })), 188 | ]; 189 | } 190 | 191 | static decrease( 192 | threshold: number, 193 | ): [ 194 | OperatorFunction, Data[]>, 195 | OperatorFunction[], Data[]>, 196 | OperatorFunction[], Data>, 197 | ] { 198 | return [ 199 | Operators.Window.fixedSizeSliding(2), 200 | filter( 201 | (data) => 202 | data.length === 2 && data[0].value - data[1].value >= threshold, 203 | ), 204 | map(([_, snd]) => snd), 205 | ]; 206 | } 207 | }; 208 | 209 | static FlowControl = class { 210 | static rateLimit(time: Duration) { 211 | return throttleTime(time.toMillis()); 212 | } 213 | 214 | static onErrorRetry(): [ 215 | OperatorFunction, 216 | MonoTypeOperatorFunction, 217 | ] { 218 | return [ 219 | catchError((it) => { 220 | console.error(it); 221 | return timer(1000).pipe(mergeMap(() => throwError(it))); 222 | }), 223 | retry(), 224 | ]; 225 | } 226 | }; 227 | 228 | static Utility = class { 229 | static log( 230 | level: PipeLogLevel, 231 | msg?: string, 232 | ): MonoTypeOperatorFunction { 233 | return tap((value: T) => { 234 | if (level >= pipeLogLevel) { 235 | msg 236 | ? console.log(`${msg}: ${JSON.stringify(value)}`) 237 | : console.log(JSON.stringify(value)); 238 | } 239 | return value; 240 | }); 241 | } 242 | }; 243 | } 244 | -------------------------------------------------------------------------------- /src/transformation-pipelines.ts: -------------------------------------------------------------------------------- 1 | import { Data, SubscriberEvent, SubscriberState } from './data-model'; 2 | import { Operators } from './transformation-pipeline-operators'; 3 | import { TransformationPipeline } from './ports'; 4 | import { Duration } from 'luxon'; 5 | 6 | export interface FixedSizeWindow { 7 | size: number; 8 | } 9 | 10 | export interface FixedSizeSlidingWindow { 11 | size: number; 12 | } 13 | 14 | export interface FixedTimeWindow { 15 | timeSpan: Duration; 16 | } 17 | 18 | export type Trigger = 19 | | RisingEdgeTrigger 20 | | FallingEdgeTrigger 21 | | IncreaseTrigger 22 | | DecreaseTrigger; 23 | 24 | export interface RisingEdgeTrigger { 25 | type: 'rising-edge'; 26 | threshold: number; 27 | limit?: number; 28 | } 29 | 30 | export interface FallingEdgeTrigger { 31 | type: 'falling-edge'; 32 | threshold: number; 33 | limit?: number; 34 | } 35 | 36 | export interface IncreaseTrigger { 37 | type: 'increase'; 38 | threshold: number; 39 | } 40 | 41 | export interface DecreaseTrigger { 42 | type: 'decrease'; 43 | threshold: number; 44 | } 45 | 46 | export type RateLimit = ThrottleTimeRateLimit; 47 | 48 | export interface ThrottleTimeRateLimit { 49 | type: 'throttle-time'; 50 | timeSpan: Duration; 51 | } 52 | 53 | function createTriggerOperator(trigger: Trigger) { 54 | switch (trigger.type) { 55 | case 'falling-edge': 56 | return Operators.Trigger.fallingEdge(trigger.threshold, trigger.limit); 57 | case 'rising-edge': 58 | return Operators.Trigger.risingEdge(trigger.threshold, trigger.limit); 59 | case 'increase': 60 | return Operators.Trigger.increase(trigger.threshold); 61 | case 'decrease': 62 | return Operators.Trigger.decrease(trigger.threshold); 63 | } 64 | throw new Error('Should not happen'); 65 | } 66 | 67 | export interface Diff { 68 | added: E[]; 69 | removed: E[]; 70 | } 71 | 72 | /** 73 | * A set of commonly-used pipelines 74 | */ 75 | 76 | export interface Change { 77 | prev: T; 78 | current: T; 79 | } 80 | 81 | export class Pipelines { 82 | static change( 83 | compareBy: (e1: E, e2: E) => boolean, 84 | rateLimit?: RateLimit, 85 | ): TransformationPipeline> { 86 | return Pipelines.createNew>((upstream) => 87 | upstream 88 | .pipe(Operators.Window.fixedSizeSliding(2)) 89 | .pipe(Operators.Transform.filter((it) => it.length === 2)) 90 | .pipe( 91 | Operators.Transform.filter( 92 | ([d1, d2]) => !compareBy(d1.value, d2.value), 93 | ), 94 | ) 95 | .pipe( 96 | Operators.Transform.map(([d1, d2]) => { 97 | const change: Change = { 98 | prev: d1.value, 99 | current: d2.value, 100 | }; 101 | const data: Data, T> = { 102 | value: change, 103 | context: d2.context, 104 | }; 105 | return data; 106 | }), 107 | ) 108 | .pipe( 109 | rateLimit 110 | ? Operators.FlowControl.rateLimit(rateLimit.timeSpan) 111 | : Operators.Transform.identity(), 112 | ), 113 | ); 114 | } 115 | 116 | static added( 117 | compareBy: (e1: E, e2: E) => boolean, 118 | rateLimit?: RateLimit, 119 | ): TransformationPipeline { 120 | return Pipelines.createNew((upstream) => 121 | upstream 122 | .pipe(Operators.Window.fixedSizeSliding(2)) 123 | .pipe(Operators.Transform.filter((it) => it.length === 2)) 124 | .pipe( 125 | Operators.Transform.map(([d1, d2]) => { 126 | const added = d2.value.filter( 127 | (e2) => !d1.value.find((e1) => compareBy(e1, e2)), 128 | ); 129 | const data: Data = { 130 | value: added, 131 | context: d2.context, 132 | }; 133 | return data; 134 | }), 135 | ) 136 | .pipe( 137 | Operators.Transform.filter(({ value: added }) => added.length > 0), 138 | ) 139 | .pipe( 140 | rateLimit 141 | ? Operators.FlowControl.rateLimit(rateLimit.timeSpan) 142 | : Operators.Transform.identity(), 143 | ), 144 | ); 145 | } 146 | 147 | static removed( 148 | compareBy: (e1: E, e2: E) => boolean, 149 | rateLimit?: RateLimit, 150 | ): TransformationPipeline { 151 | return Pipelines.createNew((upstream) => 152 | upstream 153 | .pipe(Operators.Window.fixedSizeSliding(2)) 154 | .pipe(Operators.Transform.filter((it) => it.length === 2)) 155 | .pipe( 156 | Operators.Transform.map(([d1, d2]) => { 157 | const removed = d1.value.filter( 158 | (e1) => !d2.value.find((e2) => compareBy(e1, e2)), 159 | ); 160 | const data: Data = { 161 | value: removed, 162 | context: d2.context, 163 | }; 164 | return data; 165 | }), 166 | ) 167 | .pipe( 168 | Operators.Transform.filter(({ value: added }) => added.length > 0), 169 | ) 170 | .pipe( 171 | rateLimit 172 | ? Operators.FlowControl.rateLimit(rateLimit.timeSpan) 173 | : Operators.Transform.identity(), 174 | ), 175 | ); 176 | } 177 | 178 | static diff( 179 | compareBy: (e1: E, e2: E) => boolean, 180 | rateLimit?: RateLimit, 181 | ): TransformationPipeline> { 182 | return Pipelines.createNew>((upstream) => 183 | upstream 184 | .pipe(Operators.Window.fixedSizeSliding(2)) 185 | .pipe(Operators.Transform.filter((it) => it.length === 2)) 186 | .pipe( 187 | Operators.Transform.map(([d1, d2]) => { 188 | const added = d2.value.filter( 189 | (e2) => !d1.value.find((e1) => compareBy(e1, e2)), 190 | ); 191 | const removed = d1.value.filter( 192 | (e1) => !d2.value.find((e2) => compareBy(e1, e2)), 193 | ); 194 | const diff: Diff = { 195 | added, 196 | removed, 197 | }; 198 | const data: Data, T> = { 199 | value: diff, 200 | context: d2.context, 201 | }; 202 | return data; 203 | }), 204 | ) 205 | .pipe( 206 | Operators.Transform.filter( 207 | ({ value: { added, removed } }) => 208 | added.length + removed.length > 0, 209 | ), 210 | ) 211 | .pipe( 212 | rateLimit 213 | ? Operators.FlowControl.rateLimit(rateLimit.timeSpan) 214 | : Operators.Transform.identity(), 215 | ), 216 | ); 217 | } 218 | 219 | static threshold( 220 | trigger: Trigger, 221 | rateLimit?: RateLimit, 222 | ): TransformationPipeline { 223 | const triggerOperator = createTriggerOperator(trigger); 224 | return Pipelines.createNew((upstream) => 225 | upstream 226 | .pipe(...triggerOperator) 227 | .pipe( 228 | rateLimit 229 | ? Operators.FlowControl.rateLimit(rateLimit.timeSpan) 230 | : Operators.Transform.identity(), 231 | ), 232 | ); 233 | } 234 | 235 | static averageInFixedSizeWindowThreshold( 236 | window: FixedSizeWindow, 237 | trigger: Trigger, 238 | rateLimit?: RateLimit, 239 | ): TransformationPipeline { 240 | const triggerOperator = createTriggerOperator(trigger); 241 | return Pipelines.createNew((upstream) => 242 | upstream 243 | .pipe(Operators.Window.fixedSize(window.size)) 244 | .pipe(Operators.Aggregate.avg()) 245 | .pipe(...triggerOperator) 246 | .pipe( 247 | rateLimit 248 | ? Operators.FlowControl.rateLimit(rateLimit.timeSpan) 249 | : Operators.Transform.identity(), 250 | ), 251 | ); 252 | } 253 | 254 | static averageInFixedTimeWindowThreshold( 255 | window: FixedTimeWindow, 256 | trigger: Trigger, 257 | rateLimit?: RateLimit, 258 | ): TransformationPipeline { 259 | const triggerOperator = createTriggerOperator(trigger); 260 | return Pipelines.createNew((upstream) => 261 | upstream 262 | .pipe(...Operators.Window.fixedTime(window.timeSpan)) 263 | .pipe(Operators.Aggregate.avg()) 264 | .pipe(...triggerOperator) 265 | .pipe( 266 | rateLimit 267 | ? Operators.FlowControl.rateLimit(rateLimit.timeSpan) 268 | : Operators.Transform.identity(), 269 | ), 270 | ); 271 | } 272 | 273 | static averageInFixedSizeSlidingWindowThreshold( 274 | window: FixedSizeSlidingWindow, 275 | trigger: Trigger, 276 | rateLimit?: RateLimit, 277 | ): TransformationPipeline { 278 | const triggerOperator = createTriggerOperator(trigger); 279 | return Pipelines.createNew((upstream) => 280 | upstream 281 | .pipe(Operators.Window.fixedSizeSliding(window.size)) 282 | .pipe(Operators.Aggregate.avg()) 283 | .pipe(...triggerOperator) 284 | .pipe( 285 | rateLimit 286 | ? Operators.FlowControl.rateLimit(rateLimit.timeSpan) 287 | : Operators.Transform.identity(), 288 | ), 289 | ); 290 | } 291 | 292 | static notifyNewSubscribers(): TransformationPipeline< 293 | SubscriberState, 294 | SubscriberEvent, 295 | SubscriberState 296 | > { 297 | return (source) => 298 | source.pipe(Operators.Transform.filter(({ value }) => value === 'added')); 299 | } 300 | 301 | static createNew( 302 | pipeline: TransformationPipeline, 303 | ) { 304 | return pipeline; 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/twilio-sms-notification-sink.ts: -------------------------------------------------------------------------------- 1 | import { Notification, ResourceId } from './data-model'; 2 | import { 3 | NotificationSink, 4 | NotificationSinkMetadata, 5 | SubscriberRepository, 6 | } from './ports'; 7 | import { Twilio } from 'twilio'; 8 | import { NotificationTypeEligibilityPredicate } from './internal/notification-type-eligibility-predicate'; 9 | 10 | /** 11 | * Sms notification 12 | */ 13 | export interface SmsNotification extends Notification { 14 | body: string; 15 | } 16 | 17 | export class TwilioSmsNotificationSink 18 | implements NotificationSink 19 | { 20 | private twilio: Twilio; 21 | 22 | constructor( 23 | private readonly twilioAccount: { username: string; password: string }, 24 | private readonly senderSmsNumber: string, 25 | private readonly subscriberRepository: SubscriberRepository, 26 | private readonly notificationTypeEligibilityPredicate: NotificationTypeEligibilityPredicate, 27 | ) { 28 | this.twilio = new Twilio(twilioAccount.username, twilioAccount.password); 29 | } 30 | 31 | async push( 32 | notification: SmsNotification, 33 | recipients: ResourceId[], 34 | { notificationMetadata }: NotificationSinkMetadata, 35 | ) { 36 | const recipientSmSNumbers = await this.subscriberRepository.findAll( 37 | recipients, 38 | ); 39 | console.log('sms-notif-sink, recipients:\n'); 40 | console.log(recipientSmSNumbers); 41 | const results = await Promise.allSettled( 42 | recipientSmSNumbers 43 | .filter(({ phoneNumber }) => phoneNumber) 44 | .filter((it) => 45 | this.notificationTypeEligibilityPredicate.isEligible( 46 | it, 47 | notificationMetadata, 48 | ), 49 | ) 50 | .map(({ phoneNumber }) => { 51 | this.twilio.messages 52 | .create({ 53 | to: phoneNumber!, 54 | from: this.senderSmsNumber, 55 | body: notification.body, 56 | }) 57 | .then(() => {}); 58 | }), 59 | ); 60 | 61 | const failedSends = results 62 | .filter((it) => it.status === 'rejected') 63 | .map((it) => it as PromiseRejectedResult); 64 | if (failedSends.length > 0) { 65 | console.log( 66 | `Failed to send dialect SMS notification to ${ 67 | failedSends.length 68 | } recipient(s), reasons: 69 | ${failedSends.map((it) => it.reason)} 70 | `, 71 | ); 72 | } 73 | return; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "esModuleInterop": true, 14 | "module": "commonjs", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "outDir": "lib/cjs", 19 | "noEmit": false, 20 | "declaration": true 21 | }, 22 | "include": [ 23 | "**/*.ts", 24 | ], 25 | "exclude": [ 26 | "jest.config.ts", 27 | "node_modules", 28 | "lib", 29 | "examples" 30 | ], 31 | "type": "module" 32 | } 33 | --------------------------------------------------------------------------------