Compare commits
248 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9ad006550 | |||
| eacee88f45 | |||
| 51432feb74 | |||
| 2398f85081 | |||
| b1fb567c28 | |||
| 8c89ed3334 | |||
| 32b9dbcf94 | |||
| 7d84f59c1e | |||
| d22ef06525 | |||
| 595df9ae62 | |||
| ceb862f484 | |||
| 49846df811 | |||
| 5fec008f09 | |||
| 90f8b088ff | |||
| a1d464325d | |||
| a17442b016 | |||
| cb3b068fab | |||
| 26a5913734 | |||
| 078beb1689 | |||
| 05a2f6f8e1 | |||
| 7fc6358e40 | |||
| 70ce15b271 | |||
| 97b0019d99 | |||
| c419101750 | |||
| fdb2944380 | |||
| 13b6d96f8c | |||
| feae0b3c08 | |||
| 64854b128f | |||
| 46fc33ca15 | |||
| f02eeb0af9 | |||
| f95938d2ac | |||
| c3f2f61982 | |||
| 124751e62a | |||
| 81569eb66e | |||
| 66d959282d | |||
| b0a06f8a9d | |||
| 07f0144354 | |||
| 11beadfb7d | |||
| 54faa44e84 | |||
| f121a48466 | |||
| 92436308c0 | |||
| 8ba20d8fa3 | |||
| 92a37ae3a5 | |||
| db9d9d93df | |||
| 506dfe6860 | |||
| 813c3622e9 | |||
| d0208e698b | |||
| 2a51c5a1a3 | |||
| ee9125ff02 | |||
| 6caadf5da5 | |||
| c98b7c4e24 | |||
| 489c40c847 | |||
| 0d2d9a043c | |||
| 9bb9c1cdbe | |||
| 81d6fbfdaa | |||
| b72d63bdd2 | |||
| 1319651079 | |||
| f6fbd5e5b7 | |||
| 9d5e24dac5 | |||
| 4ad2702e4d | |||
| 2a2da8fa7a | |||
| 64ccb0cf11 | |||
| 8b3abd6a06 | |||
| 63b3d82c2f | |||
| 358e9af01d | |||
| f9cab846b9 | |||
| 8a66983f63 | |||
| b45a176c4f | |||
| 86d1a2413a | |||
| 763ee294ba | |||
| 9a7db7bcff | |||
| 2c2de7b236 | |||
| b9b49a8733 | |||
| 32f09d59e6 | |||
| ddb8b131c0 | |||
| ab8e1f0d06 | |||
| 3f25622abf | |||
| 9692ef5ceb | |||
| 2c29687712 | |||
| a392e6a00c | |||
| 59a5c2df51 | |||
| 9872afcf98 | |||
| 55fd404449 | |||
| faa844efa1 | |||
| 1d02ffaf18 | |||
| 859a31e08b | |||
| a5f18cf45a | |||
| c669c61ba7 | |||
| ec1df6d476 | |||
| 50ecc88b99 | |||
| 605e34b6ad | |||
| 780cd7fcef | |||
| f447cb1ca0 | |||
| ee28eca61c | |||
| d629e3cca1 | |||
| 43a320253d | |||
| 5ffababfe5 | |||
| e77bfc77e0 | |||
| 93903bf072 | |||
| 2b4a74f672 | |||
| 8ffb612496 | |||
| 34f86dda3b | |||
| 81d3d324e2 | |||
| 6d8100d200 | |||
| 3e53699b05 | |||
| 8cca0f520b | |||
| 908fff6fe3 | |||
| da7742985f | |||
| 4077d014e4 | |||
| ab0938e91b | |||
| 547f619112 | |||
| 4008e1d7ed | |||
| 910a8d3651 | |||
| 0e4ec8d8e6 | |||
| 2d08718389 | |||
| 96306eb5e0 | |||
| 517d50242f | |||
| cf8163ef5c | |||
| d6c7648bf1 | |||
| 05b0bd9fb4 | |||
| 6495b67d1f | |||
| c37edf970d | |||
| b38961205a | |||
| 7e1c04ae71 | |||
| 49a4b0ce11 | |||
| 42c2a87238 | |||
| aefbba1f37 | |||
| b8b032a5fb | |||
| 2eea62c3ce | |||
| 22327886ac | |||
| 3126d890dc | |||
| 60abac1bc0 | |||
| 37d331588d | |||
| 6ddaf067a9 | |||
| e281297da5 | |||
| f46436b903 | |||
| 6442576a38 | |||
| 93f1814fd2 | |||
| d97b82e572 | |||
| 5e15aaadc3 | |||
| 5fce178cc0 | |||
| 2938a11685 | |||
| 3dc8751189 | |||
| fe8b8a7131 | |||
| d7f59db79e | |||
| a98dfecd17 | |||
| 84053d5a20 | |||
| 3365bf26dc | |||
| 3d5c646a1c | |||
| 2d97d20b37 | |||
| 731b6308e0 | |||
| c1742f0d1b | |||
| d57339a7c1 | |||
| 0833d402f2 | |||
| 4054571929 | |||
| afc47d328f | |||
| d6925ff4b6 | |||
| a372a179d1 | |||
| f8c39e3446 | |||
| 868e5f4ed1 | |||
| e6ce7ea859 | |||
| e6d1ddc5d8 | |||
| 8124930841 | |||
| 421bd9f757 | |||
| b3bc70baa3 | |||
| dbfff6b5f4 | |||
| 69004cdcf4 | |||
| c1511f233c | |||
| 56d23efcfa | |||
| 31c6da3be4 | |||
| 6acda311db | |||
| 9a529492d0 | |||
| f05cfc9895 | |||
| 659637c6a5 | |||
| 37bf4dcadd | |||
| 950138bd72 | |||
| c578891947 | |||
| b852731fbb | |||
| 6e8d9b4c9a | |||
| 24b57e026b | |||
| 3dd0af6157 | |||
| a4a64affd4 | |||
| a20a26e748 | |||
| 4fb9091870 | |||
| 7b29422a57 | |||
| 3496053e11 | |||
| fd54c86240 | |||
| 064e27f05d | |||
| 89d38c04aa | |||
| 6c027c3c8f | |||
| fad8f44ad1 | |||
| 5f517a5c70 | |||
| 09142cedd1 | |||
| 2bb7bad947 | |||
| d71c61fc56 | |||
| 3f9e7f4bda | |||
| e2261942a0 | |||
| a015e535f0 | |||
| 6c3c99123b | |||
| c96f9f10b4 | |||
| 23fc242dde | |||
| c1ca53f513 | |||
| b8dc408f0f | |||
| b129381cf2 | |||
| 0141643b66 | |||
| 36cdf50c7c | |||
| f660810fb8 | |||
| 8372cf71d4 | |||
| 4adad0041a | |||
| 4c23370735 | |||
| da948c4f4b | |||
| c1b9aee818 | |||
| 0f187803ae | |||
| 5423d5464f | |||
| 251b64ccf2 | |||
| 3e9e743751 | |||
| 1fc7020b84 | |||
| 0137dac540 | |||
| 95d594457c | |||
| 416c46d4cf | |||
| ba617fa7d7 | |||
| 35aedadfe9 | |||
| 888c8a7460 | |||
| d6512b2ba5 | |||
| 92391c48cb | |||
| c954b3743f | |||
| 26c1c9f4b2 | |||
| ef149bd260 | |||
| 56e5a5c8a4 | |||
| a1b16400d5 | |||
| 6bc46a8b90 | |||
| 519293ca03 | |||
| e8dfa4bea7 | |||
| fb6d254898 | |||
| 7faf4312b1 | |||
| 3710f99270 | |||
| ea9a839886 | |||
| 94f2de81ea | |||
| 5ea825f5eb | |||
| 88306d6a63 | |||
| 80ebc1b1fa | |||
| f10dadbd9f | |||
| 4085ad983c | |||
| 63776c7702 | |||
| ce8d146ed4 | |||
| b45a5c60f7 | |||
| 1c62f16117 | |||
| 67a0d54f74 |
@@ -19,3 +19,8 @@ Based on the instructions [here](https://github.com/changesets/changesets/blob/m
|
||||
|
||||
1. Run `pnpm run changeset:version`
|
||||
2. Run `pnpm run changeset:release`
|
||||
|
||||
## Pre-release instructions
|
||||
|
||||
- Switch into pre-release mode by running `pnpm run changeset:next`.
|
||||
- Switch back into normal mode by running `pnpm run changeset:normal`.
|
||||
|
||||
@@ -1,201 +1,25 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
Copyright (c) 2022 API Hero Inc.
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
1. Definitions.
|
||||
- All content that resides under any "ee/" directory of this repository, if such directories exists, are licensed under the license defined in "ee/LICENSE".
|
||||
- All third party components incorporated into the Trigger.dev Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,377 +1,204 @@
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
[](https://twitter.com/triggerdotdev) [](https://www.youtube.com/@triggerdotdev)
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
|
||||
[](https://twitter.com/triggerdotdev)
|
||||
|
||||
[](https://docs.trigger.dev)
|
||||
[Website](https://trigger.dev) | [Community](https://discord.gg/JtBAxBr2m3) | [Docs](https://docs.trigger.dev)
|
||||
</div>
|
||||
|
||||
|
||||
# **⚡️ Trigger.dev**
|
||||
### **The developer-first open source Zapier alternative.**
|
||||
|
||||
# ⚙️ Effortless automation built for developers
|
||||
|
||||
Trigger workflows from APIs, on a schedule, or on demand. API calls are easy with authentication handled for you. Add durable delays that survive server restarts.
|
||||
|
||||

|
||||
|
||||
# 💻 Developer-first features
|
||||
|
||||
## In your codebase
|
||||
|
||||
Trigger.dev is code-first so you can create workflows where they belong: in your codebase. Version control, localhost, test, review, and deploy like you're used to.
|
||||
|
||||
## Secure by design
|
||||
|
||||
Your workflows run on your servers, not ours. We only receive the data you choose to send to us.
|
||||
Trigger.dev is an open source platform that makes it easy for developers to create event-driven background tasks directly in their code. Build, test and run workflows locally using our SDK. Subscribe to webhooks, schedule jobs, run background jobs and add long delays easily and reliably. In our web app you get full visibility of every run your workflow has ever made making it easier to monitor and debug.
|
||||
|
||||
|
||||
|
||||
# Hundreds of Integrations
|
||||
# **⭐️ Features:**
|
||||
|
||||
Subscribe to API changes and make requests, we’ll handle authentication for you.
|
||||
- 👂 Easily subscribe to [webhooks](https://docs.trigger.dev/triggers/webhooks)
|
||||
— they work locally without tunnelling.
|
||||
- 🔥 Fire your own [custom events](https://docs.trigger.dev/triggers/custom-events)—a single event can trigger multiple workflows.
|
||||
- 📆 [Schedule workflows](https://docs.trigger.dev/triggers/scheduled)—easily repeat tasks or use CRON syntax for advanced cases.
|
||||
- 🚦 Add [long delays](https://docs.trigger.dev/functions/delays) inside workflows (up to a year) and they will pick up where they left off.
|
||||
- 🤝 When your server goes down [it’s not a problem](https://docs.trigger.dev/guides/resumability), workflows will reconnect and continue.
|
||||
- 🪧 [View every step of every run](https://docs.trigger.dev/viewing-runs), with data, previews and errors.
|
||||
- 👋 Connect to and authenticate with APIs using our custom integrations.
|
||||
- 🚗 If you have a custom use case, we support [Fetch for calling any HTTP endpoint](https://docs.trigger.dev/functions/fetch) or [webhooks](https://docs.trigger.dev/triggers/webhooks) for subscribing to events from APIs.
|
||||
- 📡 All API calls are automatically retried with exponential back off.
|
||||
- 😀 TypeScript SDK, so whether you’re using JavaScript or TypeScript you will have a great experience.
|
||||
|
||||
|
||||
|
||||
# 🚀 Workflow examples
|
||||
# **🌱 Documentation:**
|
||||
|
||||
## 🔄 Sync GitHub issues to Linear
|
||||
- [Getting Started with Trigger.dev](https://docs.trigger.dev/getting-started)
|
||||
- Example workflows
|
||||
- [Welcome email drip campaign using Resend and Slack](https://docs.trigger.dev/examples/resend)
|
||||
- [Post to Slack when a GitHub issue is created or modified](https://docs.trigger.dev/examples/slack)
|
||||
- [Create a new product on Shopify](https://docs.trigger.dev/examples/shopify)
|
||||
- [When a GitHub repo is starred, post information about the user to Slack](https://docs.trigger.dev/examples/github)
|
||||
- Triggers:
|
||||
- [Webhooks](https://docs.trigger.dev/triggers/webhooks)
|
||||
- [Custom events](https://docs.trigger.dev/triggers/custom-events)
|
||||
- [Scheduled](https://docs.trigger.dev/triggers/scheduled)
|
||||
- Functions:
|
||||
- [Fetch](https://docs.trigger.dev/functions/fetch)
|
||||
- [Logging](https://docs.trigger.dev/functions/logging)
|
||||
- [Delays](https://docs.trigger.dev/functions/delays)
|
||||
- [Send event](https://docs.trigger.dev/functions/send-event)
|
||||
- [Loops, conditionals, etc](https://docs.trigger.dev/functions/loops-conditionals-etc)
|
||||
|
||||
 
|
||||
|
||||
|
||||
Triggered when a GitHub issue is created or updated. Query your database to map GitHub user ids to Linear user ids. Then create or update Linear issues.",
|
||||
# 🔬 **Anatomy of a workflow**
|
||||
|
||||
```Typescript
|
||||
* You create workflows in code on your server using our SDK
|
||||
* Each API integration is a separate package, e.g. `@trigger.dev/slack`
|
||||
* Each workflow has an event that triggers it, e.g. `github.events.newStarEvent`, `scheduleEvent`, `customEvent`
|
||||
* Each workflow has a `run` function that is called when the event is triggered
|
||||
* If we don't have an integration for the API you want to use, you can use `fetch` to call any HTTP endpoint and `webhookEvent` to subscribe to webhooks
|
||||
|
||||
import { Trigger, github, linear } from "@trigger.dev/sdk";
|
||||
## **Example workflows**
|
||||
|
||||
<details open><summary> Post to Slack when a GitHub issue is created or modified
|
||||
</summary>
|
||||
|
||||
_Integrations required: Slack, GitHub_
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import * as github from "@trigger.dev/github";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
new Trigger({
|
||||
name: "Sync Github issues to Linear",
|
||||
on: github.issueEvent({
|
||||
repo: "acme/website",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const { issue, action } = event;
|
||||
|
||||
// Find the user in our local database
|
||||
const assignee = await findUserByGithubId(issue.assignee?.id);
|
||||
|
||||
if (action === "opened") {
|
||||
await linear.issueCreate({
|
||||
id: issue.id,
|
||||
title: issue.title,
|
||||
description: issue.body,
|
||||
assigneeId: assignee?.linearId,
|
||||
teamId: ctx.env.LINEAR_TEAM_ID,
|
||||
id: "new-github-star-to-slack",
|
||||
name: "New GitHub Star: triggerdotdev/trigger.dev",
|
||||
apiKey: "<my_api_key>",
|
||||
on: github.events.newStarEvent({
|
||||
repo: "triggerdotdev/trigger.dev",
|
||||
}),
|
||||
run: async (event) => {
|
||||
await slack.postMessage("github-stars", {
|
||||
channelName: "github-stars",
|
||||
text: `New GitHub star from \n<${event.sender.html_url}|${event.sender.login}>`,
|
||||
});
|
||||
} else {
|
||||
await linear.issueUpdate(issue.id, {
|
||||
assigneeId: assignee?.linearId,
|
||||
stateId:
|
||||
action === "closed"
|
||||
? ctx.env.LINEAR_CLOSED_STATE_ID
|
||||
: ctx.env.LINEAR_OPEN_STATE_ID,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
}).listen();
|
||||
|
||||
```
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
## ✉️ Send emails to new users
|
||||
<details><summary>Welcome email drip campaign
|
||||
</summary>
|
||||
|
||||
  
|
||||
_Integrations required: Slack, Resend_
|
||||
|
||||
Triggered on demand by your other code when a user is created. We wait for 3 hours then send a follow-up email if the user hasn’t completed onboarding yet.
|
||||
|
||||
```Typescript
|
||||
import { Trigger, customEvent, slack, mailgun } from "@trigger.dev/sdk";
|
||||
```ts
|
||||
import { customEvent, Trigger, sendEvent } from "@trigger.dev/sdk";
|
||||
import * as resend from "@trigger.dev/resend";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
import { getUser } from "../db";
|
||||
import { InactiveEmail, TipsEmail, WelcomeEmail } from "./email-templates";
|
||||
|
||||
new Trigger({
|
||||
name: "Send Email to New Users",
|
||||
on: customEvent<UserEvent>({ name: "user.created" }),
|
||||
run: async (event, ctx) => {
|
||||
// Wait for 3 hours before continuing
|
||||
await ctx.waitFor({ hours: 3 });
|
||||
id: "welcome-email-campaign",
|
||||
name: "Welcome email drip campaign",
|
||||
apiKey: "<my_api_key>",
|
||||
on: customEvent({
|
||||
name: "user.created",
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
}),
|
||||
async run(event, context) {
|
||||
//get the user data from the database
|
||||
const user = await getUser(event.userId);
|
||||
|
||||
// Lookup user in the database
|
||||
const user = await findUserById(event.id);
|
||||
await slack.postMessage("send-to-slack", {
|
||||
channelName: "new-users",
|
||||
text: `New user signed up: ${user.name} (${user.email})`,
|
||||
});
|
||||
|
||||
// only send email if user has not onboarded
|
||||
if (!user.hasOnboarded) {
|
||||
await mailgun.send({
|
||||
to: user.email,
|
||||
subject: "Welcome to our app!",
|
||||
body: `Welcome to our app ${user.name}!}`,
|
||||
});
|
||||
//Send the first email
|
||||
const welcomeResponse = await resend.sendEmail("welcome-email", {
|
||||
from: "Trigger.dev <james@email.trigger.dev>",
|
||||
replyTo: "James <james@trigger.dev>",
|
||||
to: user.email,
|
||||
subject: "Welcome to Trigger.dev",
|
||||
react: <WelcomeEmail name={user.name} />,
|
||||
});
|
||||
await context.logger.debug(
|
||||
`Sent welcome email to ${welcomeResponse.to} with id ${welcomeResponse.id}`
|
||||
);
|
||||
|
||||
await slack.sendMessage({
|
||||
text: `Welcome email sent to ${user.email}`,
|
||||
//wait 1 day, check if the user has created a workflow and send the appropriate email
|
||||
await context.waitFor("wait-a-while", { days: 1 });
|
||||
const updatedUser = await getUser(event.userId);
|
||||
|
||||
if (updatedUser.hasOnboarded) {
|
||||
await resend.sendEmail("onboarding-complete", {
|
||||
from: "Trigger.dev <james@email.trigger.dev>",
|
||||
replyTo: "James <james@trigger.dev>",
|
||||
to: updatedUser.email,
|
||||
subject: "Pro tips for workflows",
|
||||
react: <TipsEmail name={updatedUser.name} />,
|
||||
});
|
||||
} else {
|
||||
await slack.sendMessage({
|
||||
text: `User ${user.email} has already onboarded`,
|
||||
await resend.sendEmail("onboarding-incomplete", {
|
||||
from: "Trigger.dev <james@email.trigger.dev>",
|
||||
replyTo: "James <james@trigger.dev>",
|
||||
to: updatedUser.email,
|
||||
subject: "Help with your first workflow",
|
||||
react: <InactiveEmail name={updatedUser.name} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
## 🚨 Escalate critical incidents
|
||||
|
||||
   
|
||||
|
||||
Triggered when an Intercom incident happens. We create a Linear issue, send a Slack message and, if it’s an urgent incident, we alert whoever is on call.
|
||||
|
||||
```Typescript
|
||||
import { Trigger, intercom, linear, slack, pagerduty } from "@trigger.dev/sdk";
|
||||
|
||||
new Trigger({
|
||||
name: "Intercom Incident",
|
||||
on: intercom.newIncident(),
|
||||
run: async (event, ctx) => {
|
||||
// Find the customer in the database
|
||||
const customer = await db.query("SELECT * FROM users WHERE email = $1", [
|
||||
event.email,
|
||||
]);
|
||||
|
||||
// Create linear ticket
|
||||
const ticket = await linear.issueCreate({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
assigneeId: ctx.env.LINEAR_ASSIGNEE_ID,
|
||||
teamId: ctx.env.LINEAR_TEAM_ID,
|
||||
});
|
||||
|
||||
// notify account manager
|
||||
await slack.sendMessage({
|
||||
text: `New incident for ${customer.name} in Linear: ${ticket.url}`,
|
||||
});
|
||||
|
||||
if (event.severity === "urgent") {
|
||||
// Create a pagerduty incident
|
||||
await pagerduty.createIncident({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
severity: "critical",
|
||||
serviceId: ctx.env.PAGERDUTY_SERVICE_ID,
|
||||
});
|
||||
}
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
[More examples here](https://docs.trigger.dev/examples/examples)
|
||||
|
||||
|
||||
|
||||
# ⚡️ Trigger happy
|
||||
# 👀 **Viewing runs:**
|
||||
|
||||
Install our SDK and get instant access to an arsenal of triggers you can use in your code:
|
||||
One of the most powerful features of Trigger.dev is the [runs page](https://docs.trigger.dev/viewing-runs). All of the steps in a workflow, including the initial event, can be viewed in detail. See the status / output of each step, the logs, rich previews, errors and much more.
|
||||
|
||||

|
||||
|
||||
### **Webhooks**
|
||||
|
||||
Subscribe to webhooks without creating API endpoints. Plus they work locally without tunneling.
|
||||
|
||||
```Typescript
|
||||
github.issueEvent({ repo: "acme/website" })
|
||||
```
|
||||
|
||||
|
||||
### **Scheduled (CRON)**
|
||||
|
||||
Easily subscribe to a recurring schedule using human readable code or CRON syntax.
|
||||
|
||||
```Typescript
|
||||
scheduleEvent({ every: { minutes: 30 } })
|
||||
```
|
||||
|
||||
|
||||
### **Custom Events**
|
||||
|
||||
Trigger workflows from any event in your app. Send us your events, and we'll do the rest.
|
||||
|
||||
```Typescript
|
||||
customEvent<YourType>({ name: "your.event" })
|
||||
```
|
||||
|
||||
### **HTTP Endpoint**
|
||||
|
||||
Expose a HTTP endpoint to trigger your workflows with the method and path of your choice.
|
||||
|
||||
```Typescript
|
||||
httpEvent<User>({ method: "POST", path: "/users/:id" })
|
||||
```
|
||||
|
||||
### **Receive Emails**
|
||||
|
||||
Receive emails from your custom domain and trigger a workflow with the email metadata and content.
|
||||
|
||||
```Typescript
|
||||
emailEvent({ address: "support@help.customdomain.io" })
|
||||
```
|
||||
|
||||
### **AWS Event Bridge**
|
||||
|
||||
Integrate with AWS Event Bridge to trigger workflows on your own Event Bridge events.
|
||||
|
||||
```Typescript
|
||||
eventBridge<SupportRequest>({ bus: "customer-support" })
|
||||
```
|
||||
|
||||
|
||||
|
||||
# 🔋 Batteries included
|
||||
# **🏠 Running Trigger.dev locally:**
|
||||
|
||||
### **Debugging and visibility**
|
||||
|
||||
We provide a full history of all runs, so you can see exactly what happened.
|
||||
To run Trigger.dev locally, [follow these steps](https://github.com/triggerdotdev/trigger.dev/blob/main/DEVELOPMENT.md).
|
||||
|
||||
|
||||
|
||||
<img src="https://trigger.dev/_next/static/media/runs-diagram.c9ce4213.png" alt="Runs" width="400"/>
|
||||
# **👏 Contributing:**
|
||||
|
||||
We are open source and love contributions!
|
||||
|
||||
- Request a feature in our [Discord community](https://discord.gg/JtBAxBr2m3)
|
||||
- Open a PR
|
||||
|
||||
|
||||
|
||||
---
|
||||
# **🧘♂️ Self-hosting guide:**
|
||||
|
||||
_coming soon..._
|
||||
|
||||
|
||||
|
||||
### **Survives downtime**
|
||||
# **📧 Support & contact:**
|
||||
|
||||
Workflows pick up where they left off when your server or external APIs go down.
|
||||
|
||||
|
||||
|
||||
<img src="https://trigger.dev/_next/static/media/retries-diagram.d90a00d9.png
|
||||
" alt="Retries" width="400"/>
|
||||
|
||||
|
||||
|
||||
# Go from idea to production in minutes
|
||||
|
||||
## **1. Code**
|
||||
|
||||
Write workflows by creating triggers directly in your code. These can be 3rd-party integrations, custom events or on a schedule.
|
||||
|
||||
## **2. Connect**
|
||||
|
||||
When your server runs, your workflow will be registered and you can authenticate with any APIs you’re using.
|
||||
|
||||
## **3. Test**
|
||||
|
||||
When your server runs, your workflow will be registered and you can authenticate with any APIs you’re using.
|
||||
|
||||
## **3. Deploy**
|
||||
|
||||
Deploy your new workflow as you would any other code commit and inspect each workflow run in real time.
|
||||
|
||||
|
||||
|
||||
# ✅ We ❤️ Open Source!
|
||||
|
||||
You’ll always be able to host and run Trigger.dev yourself.
|
||||
|
||||
We've also created [JSON Hero](https://github.com/triggerdotdev/jsonhero-web), an open source JSON viewer used by around 55,000 developers per month.
|
||||
|
||||
|
||||
|
||||
# 🙋 FAQs
|
||||
|
||||
<details><summary> Does my data get sent to your servers?</summary><br>
|
||||
|
||||
Only what you choose to send. The main body of your workflow code runs on your infrastructure.
|
||||
For example when you do a database query, that never touches us.
|
||||
|
||||
Data we will receive (and store to display on the Runs page in your dashboard):
|
||||
|
||||
- Any data that triggers the start of a workflow
|
||||
- Any data you pass to one of our API integrations • Any data you choose to log using our logging function
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary> How is this different to Zapier, Pipedream etc?</summary><br>
|
||||
|
||||
Trigger.dev is a code-first workflow tool that lets you create workflows directly in your code, rather than using a UI builder like Zapier. This means you can stay in your own IDE and keep your internal data secure.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>How long does this take to set up?</summary><br>
|
||||
|
||||
Setting up Trigger.dev is simple and takes 2 minutes. Install our SDK to get started and check out the Getting Started documentation to start creating your first workflow.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Can I use version control or roll-backs?</summary><br>
|
||||
|
||||
Yes. You create workflows directly in your own code so it’s version controlled with everything else.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>How long does it take to code up a workflow?</summary><br>
|
||||
|
||||
A simple workflow triggering two events from different services will take about 5 minutes to create.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Do you have all the integrations I need?</summary><br>
|
||||
|
||||
Probably. Trigger.dev includes over 100 integrations including the most popular services. If you need a specific service that’s not available, you can request it, or create it yourself. We’re open source, so create an issue or Pull Request.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Can I build complex workflows?</summary><br>
|
||||
|
||||
Yes. There’s no limit to the complexity on workflows you can create. Workflows are created in code so you can write conditional, looping, branching or time delayed logic.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary> Can I run Trigger.dev locally?</summary><br>
|
||||
|
||||
Yes. Workflows are created in your code locally and, unlike webhooks, you don’t need to use tunneling to receive triggers.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>How does the pricing model work?</summary><br>
|
||||
|
||||
Our hosted product gives you free runs each month, after that you will need to select a paid tier. You can also self-host, view the open source repository for instructions.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Is Trigger.dev open source?</summary><br>
|
||||
|
||||
Yes, Trigger.dev is open source. We are strong supporters of open source software, and our first product, [jsonhero.io](https://jsonhero.io), has a thriving open source community. Trigger.dev follows in that tradition.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Is Trigger.dev a no/low-code tool?</summary><br>
|
||||
|
||||
No. Trigger.dev is designed for developers who want to create workflows directly in code, without using a UI builder like Zapier. This allows developers to stay in their familiar development environment and customise their workflows with code.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>What languages / frameworks do you support?</summary><br>
|
||||
|
||||
Currently there is support for Node.js. More frameworks will be added soon.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Can I use an API which doesn’t have webhooks?</summary><br>
|
||||
|
||||
Yes. You can use a polling trigger to subscribe is no webhook exists.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Can non-coders use this product?</summary><br>
|
||||
|
||||
Developers will need to create workflows. Anyone on the team can monitor running workflows in the Trigger dashboard.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
If you have any other questions about Trigger.dev, [drop us an email](mailto:hello@trigger.dev), and one of the founders will get back to you.
|
||||
- Join our [Discord community](https://discord.gg/JtBAxBr2m3)
|
||||
- If you have any other questions, get in touch at [hello@trigger.dev](mailto:hello@trigger.dev)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
The following example combines WhatsApp and Slack to create a workflow that allows you to receive WhatsApp messages in Slack, and use a modal to compose a reply.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```tsx whatsappToSlack.tsx
|
||||
/** @jsxImportSource jsx-slack */
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import {
|
||||
events,
|
||||
sendText,
|
||||
getMediaUrl,
|
||||
MessageEventMessage,
|
||||
} from "@trigger.dev/whatsapp";
|
||||
import JSXSlack, {
|
||||
Actions,
|
||||
Blocks,
|
||||
Button,
|
||||
Section,
|
||||
Header,
|
||||
Context,
|
||||
Image,
|
||||
Modal,
|
||||
Input,
|
||||
Textarea,
|
||||
} from "jsx-slack";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeStyle: "short",
|
||||
dateStyle: "short",
|
||||
});
|
||||
|
||||
// this trigger listens for WhatsApp messages and sends them to Slack
|
||||
new Trigger({
|
||||
id: "whatsapp-to-slack",
|
||||
name: "WhatsApp: load messages",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//this generates Slack blocks from the WhatsApp message
|
||||
const messageBody = await createMessageBody(event.message);
|
||||
|
||||
await slack.postMessage("jsx-test", {
|
||||
channelName: "whatsapp-support",
|
||||
//text appears in Slack notifications on mobile/desktop
|
||||
text: "How is your progress today?",
|
||||
//import and use JSXSlack to make creating rich messages much easier
|
||||
blocks: JSXSlack(
|
||||
<Blocks>
|
||||
<Header>From: {event.message.from}</Header>
|
||||
<Context>At: {dateFormatter.format(event.message.timestamp)}</Context>
|
||||
{messageBody}
|
||||
<Actions blockId="launch-modal">
|
||||
<Button value="reply" actionId="reply">
|
||||
Reply
|
||||
</Button>
|
||||
</Actions>
|
||||
</Blocks>
|
||||
),
|
||||
//pass the WhatsApp message to the next trigger
|
||||
metadata: {
|
||||
whatsAppMessage: event.message,
|
||||
},
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```tsx replyButtonInteraction.tsx
|
||||
//this trigger creates a Slack modal when a user presses the Reply button
|
||||
new Trigger({
|
||||
id: "whatsapp-to-slack-modal",
|
||||
name: "WhatsApp: show message composer",
|
||||
on: slack.events.blockActionInteraction({
|
||||
blockId: "launch-modal",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
if (!event.trigger_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
//get the action (pressing the reply button) and the original WhatsApp message
|
||||
const action = event.actions[0];
|
||||
const whatsAppMessage =
|
||||
event.message?.metadata?.event_payload.whatsAppMessage;
|
||||
|
||||
//generate Slack blocks from the WhatsApp message
|
||||
const messageBody = await createMessageBody(whatsAppMessage);
|
||||
|
||||
if (action.action_id === "reply" && action.type === "button") {
|
||||
//show a reply modal, with the original message and an input field for the reply
|
||||
await slack.openView(
|
||||
"Opening view",
|
||||
event.trigger_id,
|
||||
JSXSlack(
|
||||
<Modal title="Your reply" close="Cancel" callbackId="submit-message">
|
||||
<Header>Original message</Header>
|
||||
{messageBody}
|
||||
<Header>Your reply</Header>
|
||||
<Textarea
|
||||
name="message"
|
||||
label="Message"
|
||||
placeholder="Your message"
|
||||
maxLength={500}
|
||||
id="messageField"
|
||||
/>
|
||||
<Input type="submit" value="submit" />
|
||||
</Modal>
|
||||
),
|
||||
{
|
||||
onSubmit: "close",
|
||||
//pass the original WhatsApp message to the next trigger, and the original Slack message so we can thread replies
|
||||
metadata: {
|
||||
whatsAppMessage,
|
||||
thread_ts: event.message?.ts,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```tsx replyModalSubmission.tsx
|
||||
//this trigger sends the reply from Slack to WhatsApp
|
||||
new Trigger({
|
||||
id: "whatsapp-composed-slack-message",
|
||||
name: "WhatsApp: send message from Slack",
|
||||
on: slack.events.viewSubmissionInteraction({
|
||||
callbackId: "submit-message",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
await ctx.logger.info("Modal submission", { event });
|
||||
|
||||
//the message from the input field in Slack
|
||||
const usersResponse = event.view.state?.values.messageField.message
|
||||
.value as string;
|
||||
|
||||
//get the data from the previous messages/panels
|
||||
const privateMetadata =
|
||||
event.view.private_metadata && JSON.parse(event.view.private_metadata);
|
||||
await ctx.logger.info("Private metadata", privateMetadata);
|
||||
const whatsAppMessage = privateMetadata?.whatsAppMessage;
|
||||
|
||||
if (!whatsAppMessage || !usersResponse) {
|
||||
await ctx.logger.error("No message or original message", { event });
|
||||
return;
|
||||
}
|
||||
|
||||
//send WhatsApp message
|
||||
await sendText("send-whatsapp", {
|
||||
fromId: "102119172798864",
|
||||
to: whatsAppMessage.from,
|
||||
text: usersResponse,
|
||||
});
|
||||
|
||||
//send message in the Slack thread
|
||||
await slack.postMessage("slack-reply", {
|
||||
channelName: "whatsapp-support",
|
||||
text: `Replied with: ${usersResponse}`,
|
||||
blocks: JSXSlack(
|
||||
<Blocks>
|
||||
<Header>Reply from @{event.user.username}</Header>
|
||||
<Context>
|
||||
At: {dateFormatter.format(new Date())} To: {whatsAppMessage.from}
|
||||
</Context>
|
||||
<Section>{usersResponse}</Section>
|
||||
</Blocks>
|
||||
),
|
||||
thread_ts: privateMetadata?.thread_ts,
|
||||
});
|
||||
|
||||
return event;
|
||||
},
|
||||
}).listen();
|
||||
|
||||
//creates different Slack blocks depending on the type of WhatsApp message (e.g. text, image, etc.)
|
||||
async function createMessageBody(message: MessageEventMessage) {
|
||||
switch (message.type) {
|
||||
case "text": {
|
||||
return <Section>{message.text.body}</Section>;
|
||||
}
|
||||
case "image": {
|
||||
const mediaUrl = await getMediaUrl(`getImageUrl`, message.image);
|
||||
return (
|
||||
<Image
|
||||
src={mediaUrl}
|
||||
alt={message.image.caption ?? ""}
|
||||
title={message.image.caption ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "video": {
|
||||
const mediaUrl = await getMediaUrl(`getVideoUrl`, message.video);
|
||||
return <Section>{mediaUrl}</Section>;
|
||||
}
|
||||
default:
|
||||
return <Section>Unsupported message type: {message.type}</Section>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: "Changelog"
|
||||
description: "We release features and fixes weekly! See some of the changes we have made."
|
||||
---
|
||||
|
||||
## Feb 6 2023 Release
|
||||
|
||||
#### app.trigger.dev
|
||||
|
||||
- Improved the onboarding experience for new users, as well as users creating a new workflow.
|
||||
- Test editor now suggests example JSON for `customEvent` and `webhookEvent` actions.
|
||||
- Added a support widget to the bottom right of the screen.
|
||||
|
||||
#### @trigger.dev/sdk - v0.2.12
|
||||
|
||||
- Added `runOnce` and `runOnceLocalOnly` functions to support running custom idempotent actions. [Docs](/functions/run-once)
|
||||
- We will now serialize and send the Zod schemas for `customEvent` and `webhookEvent` to trigger.dev to support better test JSON generation (using `json-schema-faker`).
|
||||
|
||||
#### @trigger.dev/slack - v0.1.19
|
||||
|
||||
- Added support for opening modal views and handling view submissions. See [openView](/integrations/apis/slack/actions/open-view) and [viewSubmissionInteraction](/integrations/apis/slack/events/view-submission-interaction) docs for more info.
|
||||
- Added support for triggering on BlockKit actions and posting responses. See [blockActionInteraction](/integrations/apis/slack/events/block-action-interaction) and [postMessageResponse](/integrations/apis/slack/actions/post-message-response) docs for more info.
|
||||
- Added ability to add reactions to messages. See [addReaction](/integrations/apis/slack/actions/add-reaction) docs for more info.
|
||||
- You can now post a message in a thread using the `thread_ts` option in [postMessage](/integrations/apis/slack/actions/post-message).
|
||||
|
||||
#### @trigger.dev/whatsapp - v0.1.17
|
||||
|
||||
Initial release of our WhatsApp integration. Includes support for sending and receiving messages. See the [docs](/integrations/apis/whatsapp) for more info.
|
||||
@@ -20,4 +20,7 @@ You can use these workflows in your product by following the instructions on eac
|
||||
<Card title="Resend" icon="envelope" href="/examples/resend">
|
||||
Create a welcome email drip campaign.
|
||||
</Card>
|
||||
<Card title="WhatsApp" icon="whatsapp" href="/examples/whatsapp">
|
||||
Listen for WhatsApp messages and reply.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -6,10 +6,40 @@ description: "Here are some example workflows you could create using the GitHub
|
||||
|
||||
You can explore all the supported GitHub functions [here](/integrations/apis/github/events/push).
|
||||
|
||||
### Example workflows:
|
||||
|
||||
## When a GitHub repo is starred, post information about the user to Slack
|
||||
|
||||
Integrations required: GitHub, Slack
|
||||
|
||||
Packages required:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="npm">
|
||||
|
||||
```bash
|
||||
npm install @trigger.dev/github @trigger.dev/slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm add @trigger.dev/github @trigger.dev/slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="yarn">
|
||||
|
||||
```bash
|
||||
yarn add @trigger.dev/github @trigger.dev/slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Workflow code:
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import * as github from "@trigger.dev/github";
|
||||
@@ -35,6 +65,34 @@ new Trigger({
|
||||
|
||||
Integrations required: GitHub, Slack
|
||||
|
||||
Packages required:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="npm">
|
||||
|
||||
```bash
|
||||
npm install @trigger.dev/github @trigger.dev/slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm add @trigger.dev/github @trigger.dev/slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="yarn">
|
||||
|
||||
```bash
|
||||
yarn add @trigger.dev/github @trigger.dev/slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Workflow code:
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import * as github from "@trigger.dev/github";
|
||||
|
||||
@@ -5,17 +5,47 @@ description: "Here are some example workflows you could create using the Resend
|
||||
---
|
||||
|
||||
<Tip>
|
||||
Resend is in private beta, if you would like access please contact us at{" "}
|
||||
<a href="mailto:help@trigger.dev">help@trigger.dev</a>
|
||||
Resend.com is currently in private beta but you can request early access
|
||||
[here](https://resend.com/).
|
||||
</Tip>
|
||||
|
||||
### Example workflows:
|
||||
|
||||
## Welcome email drip campaign
|
||||
|
||||
This workflow will get triggered and a Slack notification and welcom email will be sent straight away.
|
||||
This workflow will get triggered and a Slack notification and welcome email will be sent straight away.
|
||||
1 day later we check if the user has completed onboarding, if they have, they get a 'tips' email, otherwise they get a re-engagement email.
|
||||
|
||||
Integrations required: Resend, Slack
|
||||
|
||||
Packages required:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="npm">
|
||||
|
||||
```bash
|
||||
npm install @trigger.dev/resend @trigger.dev/slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm add @trigger.dev/resend @trigger.dev/slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="yarn">
|
||||
|
||||
```bash
|
||||
yarn add @trigger.dev/resend @trigger.dev/slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Workflow code:
|
||||
|
||||
```ts
|
||||
import { customEvent, Trigger, sendEvent } from "@trigger.dev/sdk";
|
||||
import * as resend from "@trigger.dev/resend";
|
||||
@@ -80,3 +110,13 @@ new Trigger({
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
<iframe
|
||||
width="100%"
|
||||
height="440"
|
||||
src="https://www.youtube.com/embed/aFlwD0frvnQ?rel=0"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
@@ -4,10 +4,40 @@ sidebarTitle: "Shopify"
|
||||
description: "Here are some example workflows you could create using the Shopify integration."
|
||||
---
|
||||
|
||||
### Example workflows:
|
||||
|
||||
## Create a new product
|
||||
|
||||
Integrations required: Shopify
|
||||
|
||||
Packages required:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="npm">
|
||||
|
||||
```bash
|
||||
npm install @trigger.dev/shopify
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm add @trigger.dev/shopify
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="yarn">
|
||||
|
||||
```bash
|
||||
yarn add @trigger.dev/shopify
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Workflow code:
|
||||
|
||||
```ts
|
||||
import { Trigger, customEvent } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -4,10 +4,40 @@ sidebarTitle: "Slack"
|
||||
description: "Here are some example workflows you could create using the Slack integration."
|
||||
---
|
||||
|
||||
### Example workflows:
|
||||
|
||||
## Post to Slack when a GitHub issue is created or modified
|
||||
|
||||
Integrations required: Slack, GitHub
|
||||
|
||||
Packages required:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="npm">
|
||||
|
||||
```bash
|
||||
npm install @trigger.dev/slack @trigger.dev/github
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm add @trigger.dev/slack @trigger.dev/github
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="yarn">
|
||||
|
||||
```bash
|
||||
yarn add @trigger.dev/slack @trigger.dev/github
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Workflow code:
|
||||
|
||||
```ts
|
||||
import { Trigger, customEvent } from "@trigger.dev/sdk";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: "WhatsApp"
|
||||
sidebarTitle: "WhatsApp"
|
||||
description: "Here are some example workflows you could create using the WhatsApp integration."
|
||||
---
|
||||
|
||||
For more information about the WhatsApp integration, check out the [integration page](/integrations/apis/whatsapp).
|
||||
|
||||
### Example workflows:
|
||||
|
||||
## Listen for WhatsApp messages and reply
|
||||
|
||||
Integrations required: WhatsApp
|
||||
|
||||
Packages required:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="npm">
|
||||
|
||||
```bash
|
||||
npm install @trigger.dev/whatsapp
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm add @trigger.dev/whatsapp
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="yarn">
|
||||
|
||||
```bash
|
||||
yarn add @trigger.dev/whatsapp
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Workflow code:
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendReaction, sendText } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "whatsapp-webhook",
|
||||
name: "Listen for WhatsApp messages and reply",
|
||||
apiKey: "<my_api_key>",
|
||||
//this listens for all WhatsApp messages sent to your WhatsApp Business account
|
||||
on: events.messageEvent({
|
||||
accountId: "<your_account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//these logs will appear on the run page
|
||||
await ctx.logger.info(`Message data`, event.message);
|
||||
await ctx.logger.info(`Phone number`, event.contacts[0]);
|
||||
|
||||
//add a 🥰 reaction to the original message
|
||||
const reactionResponse = await sendReaction("reaction", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
isReplyTo: event.message.id,
|
||||
emoji: "🥰",
|
||||
});
|
||||
|
||||
//send a text message in response
|
||||
const textResponse = await sendText("text-msg", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
text: "Hello! This is a text sent automatically from https://www.trigger.dev",
|
||||
});
|
||||
|
||||
//we support all other types of WhatsApp messages (audio, video, document, location, etc)
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
@@ -4,6 +4,15 @@ sidebarTitle: "Fetch"
|
||||
description: "A generic fetch function that can be used to call any HTTP endpoint"
|
||||
---
|
||||
|
||||
## Why use our fetch?
|
||||
|
||||
The `fetch` function provided by the trigger.dev SDK is a generic function that can be used to call any HTTP endpoint. It is designed to be used inside a `Trigger.run` function, and gives you the following benefits over using the standard [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) API:
|
||||
|
||||
- We automatically retry with exponential back off (this can be configured with the options param)
|
||||
- The request and response are displayed in your Trigger.dev run dashbaord for easy debugging
|
||||
- We automatically parse the response body as JSON and provide it as `body` on the response (and typed if you give provide a [responseSchema](/#response))
|
||||
- We will retry the request reliably, even if your workflow is stopped, e.g. you deploy in the middle of a run. See [Resumability](/guides/resumability) for more information.
|
||||
|
||||
## Usage
|
||||
|
||||
A `fetch` function is available to use inside a `Trigger.run` function through the `context` argument, and should be familiar for anyone who has used the standard [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) API, with a few modifications.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
title: "Run Once"
|
||||
sidebarTitle: "Run Once"
|
||||
description: "Perform an action only once per run, even if your server is restarted."
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
A `runOnce` function is available to use inside a `Trigger.run` function through the `context` argument, allowing you to run a callback function only once [\*](#caveats) per run, based on the key you provide. This makes it so you can write code that participates in the Trigger.dev resumability system, even if your server is restarted.
|
||||
|
||||
<Note>
|
||||
Please see our detailed [Keys and Resumability guide](/guides/resumability)
|
||||
for more information on how Trigger.dev handles resumability.
|
||||
</Note>
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
|
||||
new Trigger({
|
||||
id: "run-once-example",
|
||||
name: "Run Once Example",
|
||||
on: customEvent({
|
||||
name: "example.runOnce",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
await ctx.runOnce("Example Key", async (idempotencyKey) =>
|
||||
performIdempotentAction(idempotencyKey)
|
||||
);
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
The `performIdempotentAction` above is only called once [\*](#caveats) per run, even if the server is restarted. The `idempotencyKey` is a unique key that is generated for each run, and is used to ensure that the action is only performed once per run.
|
||||
|
||||
<Info>
|
||||
The Stripe API is a great example of an API that uses idempotency keys to
|
||||
ensure that an action is only performed once. You can read more about it in
|
||||
their [Idempotent Requests
|
||||
docs](https://stripe.com/docs/api/idempotent_requests)
|
||||
</Info>
|
||||
|
||||
Your callback can also optionally return a value, and that value will be returned from the `runOnce` function.
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
|
||||
new Trigger({
|
||||
id: "run-once-example",
|
||||
name: "Run Once Example",
|
||||
on: customEvent({
|
||||
name: "example.runOnce",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const output = await ctx.runOnce("Example Key", async (idempotencyKey) =>
|
||||
performIdempotentAction(idempotencyKey)
|
||||
);
|
||||
|
||||
// Do something with the output
|
||||
// ...
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
In your app.trigger.dev run dashboard we will also display the output of your `runOnce` function, so you can see what it returned easily:
|
||||
|
||||

|
||||
|
||||
## Caveats
|
||||
|
||||
As you may have noticed above, we've used cleverly placed asterisks above to signal that your callback passed to the `runOnce` function MAY actually be called more than once, in certain situations.
|
||||
|
||||
It's possible that in between us running the `runOnce` callback and attempting to send the result to trigger.dev, an issue occurs and we are unable to save the result. If this happens, and we [resume](/guides/resumability) the same run at a later time, your callback function will be called again (but with the same idempotency key).
|
||||
|
||||
This is why it's important that your callback function is truly idempotent. If you're not sure what that means, we recommend reading the [Stripe docs on idempotent requests](https://stripe.com/docs/api/idempotent_requests).
|
||||
|
||||
## Local Only
|
||||
|
||||
If you don't want to send the result of the `runOnce` callback to trigger.dev, you can alternatively use the `runOnceLocalOnly` function. This is useful if you want to use the `runOnce` functionality, but don't want to store the value in the Trigger.dev database.
|
||||
|
||||
<Warning>
|
||||
`runOnceLocalOnly` will ALWAYS call your callback function, so be extra sure
|
||||
your callback is truly idempotent. See below for more information.
|
||||
</Warning>
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
|
||||
new Trigger({
|
||||
id: "run-once-local-only-example",
|
||||
name: "Run Once Local Only Example",
|
||||
on: customEvent({
|
||||
name: "example.runOnce",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const output = await ctx.runOnceLocalOnly(
|
||||
"Example Key",
|
||||
async (idempotencyKey) => performIdempotentAction(idempotencyKey)
|
||||
);
|
||||
|
||||
// Do something with the output
|
||||
// ...
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
## Real World Example
|
||||
|
||||
Let's say you want to use the Stripe SDK to make an idempotent charge to a customer using trigger.dev. You can use the `runOnce` function to generate an `idempotencyKey` and pass it to the Stripe SDK, ensuring that the charge is only made once per run.
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import Stripe from "stripe";
|
||||
import z from "zod";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
|
||||
new Trigger({
|
||||
id: "stripe-charge",
|
||||
name: "Stripe Charge",
|
||||
on: customEvent({
|
||||
name: "example.stripeCharge",
|
||||
schema: z.object({
|
||||
orderId: z.string(),
|
||||
customerId: z.string(),
|
||||
amount: z.number(),
|
||||
}),
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const { customerId, amount, orderId } = event.data;
|
||||
|
||||
const stripeResponse = await ctx.runOnce(
|
||||
orderId,
|
||||
async (idempotencyKey) => {
|
||||
return await stripe.charges.create(
|
||||
{
|
||||
amount,
|
||||
currency: "usd",
|
||||
customer: customerId,
|
||||
},
|
||||
{
|
||||
idempotencyKey,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return stripeResponse;
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
@@ -29,7 +29,7 @@ npm install @trigger.dev/sdk @trigger.dev/slack zod
|
||||
<Tab title="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm install @trigger.dev/sdk @trigger.dev/slack zod
|
||||
pnpm add @trigger.dev/sdk @trigger.dev/slack zod
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -4,7 +4,11 @@ sidebarTitle: "Send Email"
|
||||
description: "Send email using Resend.com"
|
||||
---
|
||||
|
||||
Send email using Resend.com, which supports sending emails using React, HTML, and plain text. Resend.com is currently in private beta but you can request early access [here](https://resend.com/).
|
||||
<Tip>
|
||||
Resend.com is currently in private beta but you can request early access
|
||||
[here](https://resend.com/).
|
||||
</Tip>
|
||||
|
||||
|
||||
<Note>
|
||||
If you'd like to render emails using React, please see the [React.email
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: "Add Reaction"
|
||||
sidebarTitle: "Add Reaction"
|
||||
description: "Add a reaction to a message in Slack."
|
||||
---
|
||||
|
||||
Adds an emoji reaction to a message in Slack. Useful when using the [blockActionInteraction](../events/block-action-interaction) event.
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="options" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="name" type="string" required={true}>
|
||||
The name of the emoji to add as a reaction (without colons). (Check out [this emoji cheatsheet](https://www.webfx.com/tools/emoji-cheat-sheet/) to see what's available.)
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="timestamp" type="string" required={true}>
|
||||
The timestamp of the message to add the reaction to. E.g. `1631234567.123456`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="channelName" type="string" required={false}>
|
||||
The name of the channel, can optionally include the `#`. E.g. `#team`.
|
||||
Alternatively you can use the `channelId` param.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="channelId" type="string" required={false}>
|
||||
The slack ID of the channel, e.g. `C04GWUTDC4W`. Can be used instead of `channelName`. Use `channelId` if the slack channel name could change.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="ok" type="boolean" default="true">
|
||||
Always true; non-ok responses will halt the workflow run and throw an error.
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts triggers.ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
new Trigger({
|
||||
id: "slack-block-interaction",
|
||||
name: "Slack Block Interaction",
|
||||
on: slack.events.blockActionInteraction({
|
||||
blockId: "issue.action.block",
|
||||
actionId: ["status-blocked", "status-help", "rating"],
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
return slack.addReaction("Adding a funny reaction to the message", {
|
||||
name: "laughing",
|
||||
timestamp: event.message.ts,
|
||||
channelId: event.channel.id,
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,634 @@
|
||||
---
|
||||
title: "Open View"
|
||||
sidebarTitle: "Open View"
|
||||
description: "Open a view for a user, usually in response to a user action."
|
||||
---
|
||||
|
||||
Open a modal view for a user after a button click or other user action. The view is a modal that blocks other user interactions with your app, and can be used to collect more complex input from users.
|
||||
|
||||
To be able to open a view, you must first acquire a `trigger_id` from a user interaction, such as a button click. See the [blockActionInteraction docs](../events/block-action-interaction) for more info on triggering on user actions.
|
||||
|
||||
<Note>
|
||||
`trigger_id`'s are valid for only 3 seconds, so make sure not to add any
|
||||
delays before calling `openView`
|
||||
</Note>
|
||||
|
||||
## Usage
|
||||
|
||||
There are 3 steps to opening a view:
|
||||
|
||||
1. Post a message to a channel with an interactive component (button, select menu, etc).
|
||||
2. Listen for a user interaction with that component (using [blockActionInteraction](../events/block-action-interaction)).
|
||||
3. Call `openView` with the `trigger_id` from the interaction.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts step1.ts
|
||||
// This is inside a Trigger.run handler, but extracted for brevity
|
||||
await slack.postMessage("New Issue", {
|
||||
channelName: "github-issues",
|
||||
text: "There is a new GitHub issue in the repo",
|
||||
metadata: { githubIssueId },
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "new.issue", // this is the block_id that we'll use to identify the message
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "reply",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Reply to Issue",
|
||||
},
|
||||
value: "reply",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts step2.ts
|
||||
new Trigger({
|
||||
id: "github-issue-slack-interaction",
|
||||
name: "GitHub Issue Slack Interaction",
|
||||
on: slack.events.blockActionInteraction({
|
||||
blockId: "new.issue",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
// Use the trigger_id from the event to open the view
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```ts step3.ts
|
||||
new Trigger({
|
||||
id: "github-issue-slack-interaction",
|
||||
name: "GitHub Issue Slack Interaction",
|
||||
on: slack.events.blockActionInteraction({
|
||||
blockId: "new.issue",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
await slack.openView("Get the reply", event.trigger_id, {
|
||||
type: "modal",
|
||||
callback_id: "reply",
|
||||
title: {
|
||||
type: "plain_text",
|
||||
text: "Reply to Issue",
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
type: "input",
|
||||
block_id: "replyInput",
|
||||
element: {
|
||||
type: "plain_text_input",
|
||||
action_id: "reply",
|
||||
},
|
||||
label: {
|
||||
type: "plain_text",
|
||||
text: "Reply",
|
||||
},
|
||||
},
|
||||
],
|
||||
submit: {
|
||||
type: "plain_text",
|
||||
text: "Submit",
|
||||
},
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Modal Validation
|
||||
|
||||
You can optional provide a validation schema (using [Zod](/guides/zod)) which we will use to validate the view submission and provide feedback to the user.
|
||||
|
||||
For example, we could make sure the `replyInput` above is more than 3 characters and less than 500 characters:
|
||||
|
||||
```ts
|
||||
await slack.openView(
|
||||
"Get the reply",
|
||||
event.trigger_id,
|
||||
{
|
||||
type: "modal",
|
||||
callback_id: "reply",
|
||||
title: {
|
||||
type: "plain_text",
|
||||
text: "Reply to Issue",
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
type: "input",
|
||||
block_id: "replyInput",
|
||||
element: {
|
||||
type: "plain_text_input",
|
||||
action_id: "reply",
|
||||
},
|
||||
label: {
|
||||
type: "plain_text",
|
||||
text: "Reply",
|
||||
},
|
||||
},
|
||||
],
|
||||
submit: {
|
||||
type: "plain_text",
|
||||
text: "Submit",
|
||||
},
|
||||
},
|
||||
{
|
||||
validationSchema: z.object({
|
||||
replyInput: z.string().min(3).max(500),
|
||||
}),
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Note that the `block_id` of the input is used as the key in the validation schema (not the `action_id`).
|
||||
|
||||
The above example will show an error message if the user submits a reply that is less than 3 characters or more than 500 characters, but you might want to provide an even better experience by using the built-in validation provided by BlockKit:
|
||||
|
||||
```ts
|
||||
await slack.openView("Get the reply", event.trigger_id, {
|
||||
type: "modal",
|
||||
callback_id: "reply",
|
||||
title: {
|
||||
type: "plain_text",
|
||||
text: "Reply to Issue",
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
type: "input",
|
||||
block_id: "replyInput",
|
||||
element: {
|
||||
type: "plain_text_input",
|
||||
action_id: "reply",
|
||||
},
|
||||
label: {
|
||||
type: "plain_text",
|
||||
text: "Reply",
|
||||
},
|
||||
min_length: 3,
|
||||
max_length: 500,
|
||||
},
|
||||
],
|
||||
submit: {
|
||||
type: "plain_text",
|
||||
text: "Submit",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
See Slack's [Block Element reference docs](https://api.slack.com/reference/block-kit/block-elements) page for more information the properties you can use to control a user's input.
|
||||
|
||||
## JSX Slack
|
||||
|
||||
You can use the [JSX Slack](https://github.com/yhatt/jsx-slack/) project to build your views in a more declarative way:
|
||||
|
||||
### Step 1: Install and configure JSX Slack
|
||||
|
||||
<Tabs>
|
||||
<Tab title="npm">
|
||||
|
||||
```bash
|
||||
npm install jsx-slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm add jsx-slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="yarn">
|
||||
|
||||
```bash
|
||||
yarn add jsx-slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
If you aren't already using React in your project, you will need to update your `tsconfig.json` file to add the `compilerOptions.jsx` option, and ensure to include `.tsx` files in your `include` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Create your Modal
|
||||
|
||||
```tsx modals.tsx
|
||||
/** @jsxImportSource jsx-slack */
|
||||
import JSXSlack, {
|
||||
Actions,
|
||||
Input,
|
||||
Textarea,
|
||||
Section,
|
||||
Header,
|
||||
Modal,
|
||||
} from "jsx-slack";
|
||||
|
||||
export function replyModal(title: string, callbackId: string) {
|
||||
return JSXSlack(
|
||||
<Modal title="Your reply" close="Cancel" callbackId={callbackId}>
|
||||
<Header>Issue title</Header>
|
||||
<Section>{title}</Section>
|
||||
<Header>Your reply</Header>
|
||||
<Textarea
|
||||
name="message"
|
||||
label="Message"
|
||||
placeholder="Your message"
|
||||
maxLength={500}
|
||||
id="messageInput"
|
||||
/>
|
||||
<Input type="submit" value="submit" />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Open the Modal
|
||||
|
||||
```ts
|
||||
import { replyModal } from "./modals";
|
||||
|
||||
await slack.openView(
|
||||
"Get the reply",
|
||||
event.trigger_id,
|
||||
replyModal("Issue title", "reply")
|
||||
);
|
||||
```
|
||||
|
||||
## Handling a View Submission
|
||||
|
||||
To get the results of a view submission, you can use the [viewSubmissionInteraction](../events/view-submission-interaction) event, providing the `callback_id` of the view you want to listen for:
|
||||
|
||||
```ts
|
||||
new Trigger({
|
||||
id: "handle-view-submission",
|
||||
name: "Handle View Submission",
|
||||
on: slack.events.viewSubmissionInteraction({
|
||||
callbackId: "reply",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
// use event.view.state.values to get the values of the inputs
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
For more information please see the [viewSubmissionInteraction docs](../events/view-submission-interaction).
|
||||
|
||||
## Passing custom data
|
||||
|
||||
There are a few ways you can pass custom data to your view submission handler when opening a view:
|
||||
|
||||
### Using the `metadata` option
|
||||
|
||||
When calling `openView`, you can pass a 4th argument which is object with a `metadata` property. This will be passed to your view submission handler as `event.view.private_metadata`:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts openView.ts
|
||||
import { replyModal } from "./modals";
|
||||
|
||||
await slack.openView(
|
||||
"Get the reply",
|
||||
event.trigger_id,
|
||||
replyModal("Issue title", "reply"),
|
||||
{
|
||||
metadata: {
|
||||
issueId: "123",
|
||||
thread_ts: event.message.ts,
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
```ts viewSubmissionInteraction.ts
|
||||
new Trigger({
|
||||
id: "handle-view-submission",
|
||||
name: "Handle View Submission",
|
||||
on: slack.events.viewSubmissionInteraction({
|
||||
callbackId: "reply",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
// event.view.private_metadata will be:
|
||||
// {
|
||||
// issueId: "123",
|
||||
// thread_ts: "123.456"
|
||||
// }
|
||||
//
|
||||
// use event.view.private_metadata.thread_ts to post a reply to a message in a thread
|
||||
await slack.postMessage("Reply", {
|
||||
channelName: "github-issues",
|
||||
text: event.view.state.values.messageInput.message.value,
|
||||
thread_ts: event.view.private_metadata.thread_ts,
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="trigger_id" type="string" required={true}>
|
||||
The trigger ID from the original event. Must be called within 3 seconds of the
|
||||
original event.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="view" type="view object" required={true}>
|
||||
The view to open.
|
||||
|
||||
<Expandable title="properties">
|
||||
<ParamField path="type" type="string" required={true}>
|
||||
Must be `modal`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="callback_id" type="string" required={false}>
|
||||
A unique identifier for the view. You can use this when you trigger on the [viewSubmissionInteraction](../events/view-submission-interaction) event. If not specified, a unique identifier will be generated. Maximum length for this field is 255 characters.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="title" type="plain_text object" required={true}>
|
||||
The title that appears in the top-left of the modal. Must be a plain_text text element with a max length of 24 characters.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="blocks" type="Array<block>" required={true}>
|
||||
An array of [blocks](https://api.slack.com/reference/block-kit/blocks) that defines the content of the view. Max of 100 blocks.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="submit_disabled" type="boolean">
|
||||
Whether the submit button is disabled until the user makes changes to the view. Defaults to `false`.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="options" type="object" required={false}>
|
||||
<Expandable title="properties">
|
||||
<ParamField path="validationSchema" type="ZodObject" required={false}>
|
||||
A [Zod](/guides/zod) object schema to validate the view submission. If the schema fails, the view submission will be rejected and the user will be shown an error message.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="onSubmit" type="string" required={false}>
|
||||
One of `"clear" | "close" | "none"` to specify what happens after the view is submitted. Defaults to `"close"`. `"clear"` will clear all the modals in the stack, `"close"` will close the current modal, and `"none"` will do nothing.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="metadata" type="object" required={false}>
|
||||
A custom object that will be passed to the [viewSubmissionInteraction](../events/view-submission-interaction) event as `event.view.private_metadata`.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="ok" type="boolean" default="true">
|
||||
Always true; non-ok responses will halt the workflow run and throw an error.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="view" type="object">
|
||||
<Expandable title="properties">
|
||||
<ResponseField name="id" type="string">
|
||||
The unique ID of the view. You can use this to update the view later.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="team_id" type="string">
|
||||
The ID of the workspace where the view was opened.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="type" type="string">
|
||||
The type of view. Always `modal`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="private_metadata" type="object">
|
||||
The metadata that was passed to the view when it was opened.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="callback_id" type="string">
|
||||
The callback ID that was passed to the view when it was opened.
|
||||
</ResponseField>
|
||||
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflow
|
||||
|
||||
The following example combines WhatsApp and Slack to create a workflow that allows you to receive WhatsApp messages in Slack, and use a modal to compose a reply.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```tsx whatsappToSlack.tsx
|
||||
/** @jsxImportSource jsx-slack */
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import {
|
||||
events,
|
||||
sendText,
|
||||
getMediaUrl,
|
||||
MessageEventMessage,
|
||||
} from "@trigger.dev/whatsapp";
|
||||
import JSXSlack, {
|
||||
Actions,
|
||||
Blocks,
|
||||
Button,
|
||||
Section,
|
||||
Header,
|
||||
Context,
|
||||
Image,
|
||||
Modal,
|
||||
Input,
|
||||
Textarea,
|
||||
} from "jsx-slack";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeStyle: "short",
|
||||
dateStyle: "short",
|
||||
});
|
||||
|
||||
// this trigger listens for WhatsApp messages and sends them to Slack
|
||||
new Trigger({
|
||||
id: "whatsapp-to-slack",
|
||||
name: "WhatsApp: load messages",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//this generates Slack blocks from the WhatsApp message
|
||||
const messageBody = await createMessageBody(event.message);
|
||||
|
||||
await slack.postMessage("jsx-test", {
|
||||
channelName: "whatsapp-support",
|
||||
//text appears in Slack notifications on mobile/desktop
|
||||
text: "How is your progress today?",
|
||||
//import and use JSXSlack to make creating rich messages much easier
|
||||
blocks: JSXSlack(
|
||||
<Blocks>
|
||||
<Header>From: {event.message.from}</Header>
|
||||
<Context>At: {dateFormatter.format(event.message.timestamp)}</Context>
|
||||
{messageBody}
|
||||
<Actions blockId="launch-modal">
|
||||
<Button value="reply" actionId="reply">
|
||||
Reply
|
||||
</Button>
|
||||
</Actions>
|
||||
</Blocks>
|
||||
),
|
||||
//pass the WhatsApp message to the next trigger
|
||||
metadata: {
|
||||
whatsAppMessage: event.message,
|
||||
},
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```tsx replyButtonInteraction.tsx
|
||||
//this trigger creates a Slack modal when a user presses the Reply button
|
||||
new Trigger({
|
||||
id: "whatsapp-to-slack-modal",
|
||||
name: "WhatsApp: show message composer",
|
||||
on: slack.events.blockActionInteraction({
|
||||
blockId: "launch-modal",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
if (!event.trigger_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
//get the action (pressing the reply button) and the original WhatsApp message
|
||||
const action = event.actions[0];
|
||||
const whatsAppMessage =
|
||||
event.message?.metadata?.event_payload.whatsAppMessage;
|
||||
|
||||
//generate Slack blocks from the WhatsApp message
|
||||
const messageBody = await createMessageBody(whatsAppMessage);
|
||||
|
||||
if (action.action_id === "reply" && action.type === "button") {
|
||||
//show a reply modal, with the original message and an input field for the reply
|
||||
await slack.openView(
|
||||
"Opening view",
|
||||
event.trigger_id,
|
||||
JSXSlack(
|
||||
<Modal title="Your reply" close="Cancel" callbackId="submit-message">
|
||||
<Header>Original message</Header>
|
||||
{messageBody}
|
||||
<Header>Your reply</Header>
|
||||
<Textarea
|
||||
name="message"
|
||||
label="Message"
|
||||
placeholder="Your message"
|
||||
maxLength={500}
|
||||
id="messageField"
|
||||
/>
|
||||
<Input type="submit" value="submit" />
|
||||
</Modal>
|
||||
),
|
||||
{
|
||||
onSubmit: "close",
|
||||
//pass the original WhatsApp message to the next trigger, and the original Slack message so we can thread replies
|
||||
metadata: {
|
||||
whatsAppMessage,
|
||||
thread_ts: event.message?.ts,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```tsx replyModalSubmission.tsx
|
||||
//this trigger sends the reply from Slack to WhatsApp
|
||||
new Trigger({
|
||||
id: "whatsapp-composed-slack-message",
|
||||
name: "WhatsApp: send message from Slack",
|
||||
on: slack.events.viewSubmissionInteraction({
|
||||
callbackId: "submit-message",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
await ctx.logger.info("Modal submission", { event });
|
||||
|
||||
//the message from the input field in Slack
|
||||
const usersResponse = event.view.state?.values.messageField.message
|
||||
.value as string;
|
||||
|
||||
//get the data from the previous messages/panels
|
||||
const privateMetadata =
|
||||
event.view.private_metadata && JSON.parse(event.view.private_metadata);
|
||||
await ctx.logger.info("Private metadata", privateMetadata);
|
||||
const whatsAppMessage = privateMetadata?.whatsAppMessage;
|
||||
|
||||
if (!whatsAppMessage || !usersResponse) {
|
||||
await ctx.logger.error("No message or original message", { event });
|
||||
return;
|
||||
}
|
||||
|
||||
//send WhatsApp message
|
||||
await sendText("send-whatsapp", {
|
||||
fromId: "102119172798864",
|
||||
to: whatsAppMessage.from,
|
||||
text: usersResponse,
|
||||
});
|
||||
|
||||
//send message in the Slack thread
|
||||
await slack.postMessage("slack-reply", {
|
||||
channelName: "whatsapp-support",
|
||||
text: `Replied with: ${usersResponse}`,
|
||||
blocks: JSXSlack(
|
||||
<Blocks>
|
||||
<Header>Reply from @{event.user.username}</Header>
|
||||
<Context>
|
||||
At: {dateFormatter.format(new Date())} To: {whatsAppMessage.from}
|
||||
</Context>
|
||||
<Section>{usersResponse}</Section>
|
||||
</Blocks>
|
||||
),
|
||||
thread_ts: privateMetadata?.thread_ts,
|
||||
});
|
||||
|
||||
return event;
|
||||
},
|
||||
}).listen();
|
||||
|
||||
//creates different Slack blocks depending on the type of WhatsApp message (e.g. text, image, etc.)
|
||||
async function createMessageBody(message: MessageEventMessage) {
|
||||
switch (message.type) {
|
||||
case "text": {
|
||||
return <Section>{message.text.body}</Section>;
|
||||
}
|
||||
case "image": {
|
||||
const mediaUrl = await getMediaUrl(`getImageUrl`, message.image);
|
||||
return (
|
||||
<Image
|
||||
src={mediaUrl}
|
||||
alt={message.image.caption ?? ""}
|
||||
title={message.image.caption ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "video": {
|
||||
const mediaUrl = await getMediaUrl(`getVideoUrl`, message.video);
|
||||
return <Section>{mediaUrl}</Section>;
|
||||
}
|
||||
default:
|
||||
return <Section>Unsupported message type: {message.type}</Section>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: "Post Message Response"
|
||||
sidebarTitle: "Post Message Response"
|
||||
description: "Post a Slack message in response to a user interaction in Slack"
|
||||
---
|
||||
|
||||
Post a message to slack when you receive a [blockActionInteraction](../events/block-action-interaction) event with a `response_url`. For example, if you publish a message with a button and the user clicks the button, you can use this action to post a message in response to the user's click.
|
||||
|
||||
There are three different types of responses you can send:
|
||||
|
||||
- **Ephemeral**: A message that only the user who clicked the button will see and do not persist in the channel or across apps. This is the default.
|
||||
- **In Channel**: A message that will be published to the channel and will persist across apps. Everyone in the channel will see the message.
|
||||
- **Update**: Update the original message with new text or blocks.
|
||||
|
||||
Here's a table with the different `message` params you can use for each type of response (`text` and `blocks` are supported for all three types of responses)
|
||||
|
||||
| Param | Ephemeral | In Channel | Update |
|
||||
| ------------------ | ----------- | -------------- | ----------- |
|
||||
| `replace_original` | `false` | `false` | `true` |
|
||||
| `response_type` | `undefined` | `"in_channel"` | `undefined` |
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="message" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="text" type="string" required={true}>
|
||||
The formatted text of the message to be published, formatted as [mrkdwn](https://api.slack.com/reference/surfaces/formatting#basics).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="blocks" type="object" required={false}>
|
||||
You can use blocks to create a rich message with images, buttons, and more.
|
||||
|
||||
You can either pass in a JavaScript object or use [JSX Slack](https://github.com/yhatt/jsx-slack/) to create blocks. We highly recommend using JSX Slack as it's much easier to read and write. See the [block example](#blocks-with-interactivity) to see it in action.
|
||||
|
||||
For full details on Slack blocks, see the [Slack API docs](https://api.slack.com/reference/block-kit/blocks).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="response_type" type="'in_channel' | undefined" required={false}>
|
||||
Set the `'in_channel'` response type to publish the message to the channel. If not specified, the message will be ephemeral.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="replace_original" type="boolean" required={false} default="false">
|
||||
Set to `true` to update the original message with new text or blocks. If not specified, the message will be ephemeral or published to the channel.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="delete_original" type="boolean" required={false} default="false">
|
||||
Set to `true` to delete the original message.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="thread_ts" type="string" required={false}>
|
||||
The "timestamp ID" of the message to reply to. If not specified, the
|
||||
message will be published as a new thread.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="ok" type="boolean" default="true">
|
||||
Always true; non-ok responses will halt the workflow run and throw an error.
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts triggers.ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
import { z } from "zod";
|
||||
|
||||
new Trigger({
|
||||
id: "slack-block-interaction",
|
||||
name: "Slack Block Interaction",
|
||||
on: slack.events.blockActionInteraction({
|
||||
blockId: "issue.action.block",
|
||||
actionId: ["status-blocked", "status-help", "rating"],
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
if (!event.response_url) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Post an ephemeral message
|
||||
return slack.postMessageResponse(
|
||||
"Added a comment to the issue",
|
||||
event.response_url,
|
||||
{
|
||||
text: `You rated your day ${action.selected_option?.value} stars`,
|
||||
replace_original: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Post an a message to the channel
|
||||
return slack.postMessageResponse(
|
||||
"Added a comment to the issue",
|
||||
event.response_url,
|
||||
{
|
||||
text: `Another rating of ${action.selected_option?.value} stars!`,
|
||||
response_type: "in_channel",
|
||||
}
|
||||
);
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -36,6 +36,33 @@ Publish Slack messages to a public or private channel in your Slack Workspace as
|
||||
For full details on Slack blocks, see the [Slack API docs](https://api.slack.com/reference/block-kit/blocks).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="thread_ts" type="string" required={false}>
|
||||
The "timestamp ID" of the message to reply to. If not specified, the
|
||||
message will be published as a new thread.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="metadata" type="object" required={false}>
|
||||
A JSON object that will be attached to the message. This can be used to
|
||||
store custom data about the message, but will not be displayed to users. Useful for passing data to a [blockActionInteraction](../events/block-action-interaction) trigger.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="username" type="string" required={false}>
|
||||
The name of the bot that will publish the message. If not specified, the
|
||||
message will be published as the Trigger.dev Slack bot.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="icon_emoji" type="string" required={false}>
|
||||
The emoji to use as the icon for the bot that will publish the message.
|
||||
If not specified, the message will be published as the Trigger.dev Slack
|
||||
bot.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="icon_url" type="string" required={false}>
|
||||
The URL of an image to use as the icon for the bot that will publish the
|
||||
message. If not specified, the message will be published as the
|
||||
Trigger.dev Slack bot.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
@@ -256,7 +283,8 @@ new Trigger({
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
//other options
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"] // make sure to include `.tsx` files
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
---
|
||||
title: "Block Actions"
|
||||
sidebarTitle: "Block Actions"
|
||||
description: "Trigger a workflow when a user interacts with a BlockKit interaction component (e.g. buttons, menus, etc.)"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
If you send a message with BlockKit components, you can trigger a workflow when a user interacts with those components. For example, you can trigger a workflow when a user clicks a button, selects an option from a menu, or submits a modal.
|
||||
|
||||
The event will be the [block_action event payload](https://api.slack.com/reference/interaction-payloads/block-actions#fields) from Slack, which includes the `actions` array. You can use the `actions` array to determine which action was triggered, and the `block_id` and `action_id` to determine which component was interacted with.
|
||||
|
||||
A very simple example would be to first send a message to a channel with a button, and then trigger a workflow when the button is clicked:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Send Message with Button
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
import { z } from "zod";
|
||||
|
||||
const BLOCK_ID = "employee-ping";
|
||||
|
||||
new Trigger({
|
||||
id: "send-message-with-button",
|
||||
name: "Send Message with Button",
|
||||
on: scheduleEvent({
|
||||
rateOf: {
|
||||
minutes: 5,
|
||||
},
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
await slack.postMessage("Are you still there?", {
|
||||
channelName: "employee-ping",
|
||||
//text appears in Slack notifications on mobile/desktop
|
||||
text: "Are you still there?",
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "Are you still there?",
|
||||
verbatim: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: BLOCK_ID, // this is the block_id that we'll use to identify the message
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "nope",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "I'm at lunch",
|
||||
emoji: true,
|
||||
},
|
||||
value: "lunch",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
action_id: "youbet",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Still here!",
|
||||
emoji: true,
|
||||
},
|
||||
value: "in-my-seat",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```typescript Listen for Button Click
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
import { z } from "zod";
|
||||
|
||||
const BLOCK_ID = "employee-ping";
|
||||
|
||||
new Trigger({
|
||||
id: "employee-ping-event",
|
||||
name: "Listen for Employee Ping",
|
||||
on: slack.events.blockActionInteraction({
|
||||
blockId: BLOCK_ID,
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const action = event.actions[0];
|
||||
|
||||
if (action.action_id === "nope") {
|
||||
await slack.postMessage("respond to employee", {
|
||||
channelName: "employee-ping",
|
||||
text: "Okay, I'll ping you again in 5 minutes.",
|
||||
});
|
||||
} else if (action.action_id === "youbet") {
|
||||
await slack.postMessage("respond to employee", {
|
||||
channelName: "employee-ping",
|
||||
text: "Great! I'll ping you again in 5 minutes.",
|
||||
});
|
||||
}
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Passing custom data
|
||||
|
||||
There are three ways to pass custom data to the workflow that is triggered by the event:
|
||||
|
||||
1. Use the `value` field of the BlockKit component. This is the easiest way to pass data, but it is limited to 2000 characters.
|
||||
2. Use the `metadata` option when sending the message. This is best for handling "context" data that relates to any possible action taken in a BlockKit message.
|
||||
|
||||
An example of using the `value` field:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts postMessage.ts
|
||||
await slack.postMessage("New Issue", {
|
||||
channelName: "github-issues",
|
||||
//text appears in Slack notifications on mobile/desktop
|
||||
text: "There is a new GitHub issue in the repo",
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "new.issue", // this is the block_id that we'll use to identify the message
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "reply",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Reply to Issue",
|
||||
},
|
||||
value: githubIssueId,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
action_id: "close",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Close Issue",
|
||||
},
|
||||
value: githubIssueId,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts trigger.ts
|
||||
new Trigger({
|
||||
id: "github-issue-slack-interaction",
|
||||
name: "GitHub Issue Slack Interaction",
|
||||
on: slack.events.blockActionInteraction({
|
||||
blockId: "new.issue",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const action = event.actions[0];
|
||||
|
||||
if (action.action_id === "reply") {
|
||||
// action.value is the githubIssueId
|
||||
} else if (action.action_id === "close") {
|
||||
// action.value is the githubIssueId
|
||||
}
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
And an example using the `metadata` option (which can be any JSON-serializable data):
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts postMessage.ts
|
||||
await slack.postMessage("New Issue", {
|
||||
channelName: "github-issues",
|
||||
//text appears in Slack notifications on mobile/desktop
|
||||
text: "There is a new GitHub issue in the repo",
|
||||
metadata: { githubIssueId },
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "new.issue", // this is the block_id that we'll use to identify the message
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "reply",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Reply to Issue",
|
||||
},
|
||||
value: "reply",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
action_id: "close",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Close Issue",
|
||||
},
|
||||
value: "close",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts trigger.ts
|
||||
new Trigger({
|
||||
id: "github-issue-slack-interaction",
|
||||
name: "GitHub Issue Slack Interaction",
|
||||
on: slack.events.blockActionInteraction({
|
||||
blockId: "new.issue",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const action = event.actions[0];
|
||||
|
||||
if (action.action_id === "reply") {
|
||||
// event.message.metadata.event_payload.githubIssueId is the githubIssueId
|
||||
} else if (action.action_id === "close") {
|
||||
// event.message.metadata.event_payload.githubIssueId is the githubIssueId
|
||||
}
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## JSX Slack
|
||||
|
||||
You can use the [JSX Slack](https://github.com/yhatt/jsx-slack/) project to build your BlockKit messages and trigger the `blockActionInteraction` event.
|
||||
|
||||
### Step 1: Install and configure JSX Slack
|
||||
|
||||
<Tabs>
|
||||
<Tab title="npm">
|
||||
|
||||
```bash
|
||||
npm install jsx-slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm add jsx-slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="yarn">
|
||||
|
||||
```bash
|
||||
yarn add jsx-slack
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
If you aren't already using React in your project, you will need to update your `tsconfig.json` file to add the `compilerOptions.jsx` option, and ensure to include `.tsx` files in your `include` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Create your BlockKit message
|
||||
|
||||
```tsx
|
||||
/** @jsxImportSource jsx-slack */
|
||||
import JSXSlack, {
|
||||
Actions,
|
||||
Blocks,
|
||||
Button,
|
||||
Section,
|
||||
Select,
|
||||
Option,
|
||||
} from "jsx-slack";
|
||||
|
||||
export function progressBlock(blockId: string) {
|
||||
return JSXSlack(
|
||||
<Blocks>
|
||||
<Section>How is your progress today?</Section>
|
||||
<Actions blockId={blockId}>
|
||||
<Button value="blocked" actionId="status-blocked">
|
||||
I'm blocked
|
||||
</Button>
|
||||
<Button
|
||||
value="help"
|
||||
actionId="status-help"
|
||||
url="https://xkcd.com/1349/"
|
||||
>
|
||||
Get help
|
||||
</Button>
|
||||
<Select actionId="rating" placeholder="Rate it!">
|
||||
<Option value="5">5 {":star:".repeat(5)}</Option>
|
||||
<Option value="4">4 {":star:".repeat(4)}</Option>
|
||||
<Option value="3">3 {":star:".repeat(3)}</Option>
|
||||
<Option value="2">2 {":star:".repeat(2)}</Option>
|
||||
<Option value="1">1 {":star:".repeat(1)}</Option>
|
||||
</Select>
|
||||
</Actions>
|
||||
</Blocks>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Please note, that to use `jsx-slack` you will need to add the `jsxImportSource` pragma to the top of your file. This is because `jsx-slack` is not a React library, but it uses the same syntax as React. The file also needs to be a `.jsx` or `.tsx` file (like when you use React).
|
||||
|
||||
### Step 3: Use your BlockKit message in a `postMessage` call
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript postMessage.ts
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
import { z } from "zod";
|
||||
import { progressBlock } from "./messages";
|
||||
|
||||
const BLOCK_ID = "issue.action.block";
|
||||
|
||||
//1. every minute see how your employees are doing, we don't recommend this frequency 😉
|
||||
new Trigger({
|
||||
id: "slack-interactivity",
|
||||
name: "Testing Slack Interactivity",
|
||||
on: scheduleEvent({
|
||||
rateOf: {
|
||||
minutes: 1,
|
||||
},
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
await slack.postMessage("jsx-test", {
|
||||
channelName: "employee-progress",
|
||||
text: "How is your progress today?",
|
||||
blocks: progressBlock(BLOCK_ID), // Pass in the block_id which we'll trigger on in the next step
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```typescript blockAction.ts
|
||||
new Trigger({
|
||||
id: "slack-block-interaction",
|
||||
name: "Slack Block Interaction",
|
||||
on: slack.events.blockActionInteraction({
|
||||
blockId: BLOCK_ID, // trigger on the block_id we passed in the previous step
|
||||
actionId: ["status-blocked", "status-help", "rating"], // we could have also left this blank to trigger on all actions
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
if (!event.message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = event.actions[0];
|
||||
|
||||
switch (action.action_id) {
|
||||
case "status-blocked": {
|
||||
return slack.addReaction("React to message", {
|
||||
name: "cry",
|
||||
timestamp: event.message.ts,
|
||||
channelId: event.channel.id,
|
||||
});
|
||||
}
|
||||
case "status-help": {
|
||||
return slack.addReaction("React to message", {
|
||||
name: "sos",
|
||||
timestamp: event.message.ts,
|
||||
channelId: event.channel.id,
|
||||
});
|
||||
}
|
||||
case "rating": {
|
||||
if (action.type != "static_select") {
|
||||
throw new Error("This action should be a select");
|
||||
}
|
||||
|
||||
//post the rating as a message that appears below the original,
|
||||
//only the user pressing the button will see this message
|
||||
return slack.postMessageResponse(
|
||||
"Added a comment to the issue",
|
||||
event.response_url,
|
||||
{
|
||||
text: `You rated your day ${action.selected_option?.value} stars`,
|
||||
replace_original: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="blockId" type="string" required={true}>
|
||||
The `block_id` of the BlockKit component that you want to listen for.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="actionId" type="string | string[]" required={false}>
|
||||
The `action_id` of the BlockKit component that you want to listen for. If you
|
||||
don't specify an `actionId`, the workflow will be triggered for any action on
|
||||
the specified `blockId`. You can also specify an array of `action_id`s to
|
||||
listen for multiple actions.
|
||||
</ParamField>
|
||||
|
||||
## Event Payload
|
||||
|
||||
Please see the [Slack Block Actions event payload](https://api.slack.com/reference/interaction-payloads/block-actions#fields) for more information.
|
||||
|
||||
<ResponseField name="type" type="string">
|
||||
The type of event. This will always be `block_actions`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="trigger_id" type="string">
|
||||
A short-lived ID that can be used to open a modal. See the [openView
|
||||
docs](../actions/open-view)
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="response_url" type="string">
|
||||
A short-lived URL that can be used to send a response to the user. See the
|
||||
[postMessageResponse docs](../actions/post-message-response) for more
|
||||
information.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="user" type="user object">
|
||||
The user that triggered the event.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="message" type="message object">
|
||||
The message that the user interacted with.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="view" type="view object">
|
||||
The view that the user interacted with.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="actions" type="Array<action>">
|
||||
An array of actions that the user interacted with. `actions.action_id` can be
|
||||
used to determine which action was triggered. (e.g. which button was clicked)
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="team" type="team object">
|
||||
The team that the user is a part of.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="channel" type="channel object">
|
||||
The channel where the interaction took place.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="container" type="object">
|
||||
<Expandable title="properties">
|
||||
<ResponseField name="type" type="string">
|
||||
The type of container. This will always be `message`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="message_ts" type="string">
|
||||
The timestamp of the message that the user interacted with.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="channel_id" type="string">
|
||||
The ID of the channel where the interaction took place.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="is_ephemeral" type="boolean">
|
||||
Whether the message is ephemeral.
|
||||
</ResponseField>
|
||||
|
||||
</Expandable>
|
||||
</ResponseField>
|
||||
@@ -0,0 +1,326 @@
|
||||
---
|
||||
title: "View Submission"
|
||||
sidebarTitle: "View Submission"
|
||||
description: "Trigger a workflow when a user submits a modal view."
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
If you open a modal view with a submit button, you can trigger a workflow when the user submits the view using this trigger. See the [openView docs](../actions/open-view) for more information on how to open a modal view.
|
||||
|
||||
The event will be the [view_submission event payload](https://api.slack.com/reference/interaction-payloads/views#view_submission) from Slack, which includes the `view.state.values` object which you can use to access the values of the inputs in the view.
|
||||
|
||||
For example, given the following view:
|
||||
|
||||
```ts
|
||||
await slack.openView("Get the reply", event.trigger_id, {
|
||||
type: "modal",
|
||||
callback_id: "message-reply",
|
||||
title: {
|
||||
type: "plain_text",
|
||||
text: "Reply to Issue",
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
type: "input",
|
||||
block_id: "replyInput",
|
||||
element: {
|
||||
type: "plain_text_input",
|
||||
action_id: "reply",
|
||||
},
|
||||
label: {
|
||||
type: "plain_text",
|
||||
text: "Reply",
|
||||
},
|
||||
},
|
||||
],
|
||||
submit: {
|
||||
type: "plain_text",
|
||||
text: "Submit",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The event payload would have the following shape:
|
||||
|
||||
```json
|
||||
{
|
||||
// ...
|
||||
"view": {
|
||||
"id": "V04N2P0G797",
|
||||
"type": "modal",
|
||||
"state": {
|
||||
"values": {
|
||||
"replyInput": {
|
||||
"reply": {
|
||||
"type": "plain_text_input",
|
||||
"value": "This is the reply!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Callback IDs
|
||||
|
||||
You can optionally provide callback ID for the view in order to only trigger on specific views. The callback ID is specified in the `callback_id` property of the view. In the example above, the callback ID is `"message-reply"`:
|
||||
|
||||
```ts
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
new Trigger({
|
||||
on: slack.events.viewSubmissionInteraction({
|
||||
callbackId: "message-reply",
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
You can also provide an array of callback IDs to trigger on multiple views:
|
||||
|
||||
```ts
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
new Trigger({
|
||||
on: slack.events.viewSubmissionInteraction({
|
||||
callbackId: ["message-reply", "another-view"],
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
Because of the way the Slack API works, you cannot update the view or push a new view through the API when handling a view submission. The only way to update or push a view in a view submission interaction is with a [response_action](https://api.slack.com/surfaces/modals/using#updating_response), which we currently do not support. If you'd like this feature, please don't hesitate to [get in touch!](/get-help)
|
||||
|
||||
## Event payload
|
||||
|
||||
Please see the [Slack View Submission event payload](https://api.slack.com/reference/interaction-payloads/views#view_submission) for more information.
|
||||
|
||||
<ResponseField name="type" type="string">
|
||||
The type of event, which will always be `view_submission`.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="view" type="view object">
|
||||
The view that the user interacted with.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="user" type="user object">
|
||||
The user that submitted the view.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="team" type="team object">
|
||||
The team that the user is a part of.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="channel" type="channel object">
|
||||
The channel where the interaction took place.
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflow
|
||||
|
||||
The following example combines WhatsApp and Slack to create a workflow that allows you to receive WhatsApp messages in Slack, and use a modal to compose a reply.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```tsx whatsappToSlack.tsx
|
||||
/** @jsxImportSource jsx-slack */
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import {
|
||||
events,
|
||||
sendText,
|
||||
getMediaUrl,
|
||||
MessageEventMessage,
|
||||
} from "@trigger.dev/whatsapp";
|
||||
import JSXSlack, {
|
||||
Actions,
|
||||
Blocks,
|
||||
Button,
|
||||
Section,
|
||||
Header,
|
||||
Context,
|
||||
Image,
|
||||
Modal,
|
||||
Input,
|
||||
Textarea,
|
||||
} from "jsx-slack";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeStyle: "short",
|
||||
dateStyle: "short",
|
||||
});
|
||||
|
||||
// this trigger listens for WhatsApp messages and sends them to Slack
|
||||
new Trigger({
|
||||
id: "whatsapp-to-slack",
|
||||
name: "WhatsApp: load messages",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//this generates Slack blocks from the WhatsApp message
|
||||
const messageBody = await createMessageBody(event.message);
|
||||
|
||||
await slack.postMessage("jsx-test", {
|
||||
channelName: "whatsapp-support",
|
||||
//text appears in Slack notifications on mobile/desktop
|
||||
text: "How is your progress today?",
|
||||
//import and use JSXSlack to make creating rich messages much easier
|
||||
blocks: JSXSlack(
|
||||
<Blocks>
|
||||
<Header>From: {event.message.from}</Header>
|
||||
<Context>At: {dateFormatter.format(event.message.timestamp)}</Context>
|
||||
{messageBody}
|
||||
<Actions blockId="launch-modal">
|
||||
<Button value="reply" actionId="reply">
|
||||
Reply
|
||||
</Button>
|
||||
</Actions>
|
||||
</Blocks>
|
||||
),
|
||||
//pass the WhatsApp message to the next trigger
|
||||
metadata: {
|
||||
whatsAppMessage: event.message,
|
||||
},
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```tsx replyButtonInteraction.tsx
|
||||
//this trigger creates a Slack modal when a user presses the Reply button
|
||||
new Trigger({
|
||||
id: "whatsapp-to-slack-modal",
|
||||
name: "WhatsApp: show message composer",
|
||||
on: slack.events.blockActionInteraction({
|
||||
blockId: "launch-modal",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
if (!event.trigger_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
//get the action (pressing the reply button) and the original WhatsApp message
|
||||
const action = event.actions[0];
|
||||
const whatsAppMessage =
|
||||
event.message?.metadata?.event_payload.whatsAppMessage;
|
||||
|
||||
//generate Slack blocks from the WhatsApp message
|
||||
const messageBody = await createMessageBody(whatsAppMessage);
|
||||
|
||||
if (action.action_id === "reply" && action.type === "button") {
|
||||
//show a reply modal, with the original message and an input field for the reply
|
||||
await slack.openView(
|
||||
"Opening view",
|
||||
event.trigger_id,
|
||||
JSXSlack(
|
||||
<Modal title="Your reply" close="Cancel" callbackId="submit-message">
|
||||
<Header>Original message</Header>
|
||||
{messageBody}
|
||||
<Header>Your reply</Header>
|
||||
<Textarea
|
||||
name="message"
|
||||
label="Message"
|
||||
placeholder="Your message"
|
||||
maxLength={500}
|
||||
id="messageField"
|
||||
/>
|
||||
<Input type="submit" value="submit" />
|
||||
</Modal>
|
||||
),
|
||||
{
|
||||
onSubmit: "close",
|
||||
//pass the original WhatsApp message to the next trigger, and the original Slack message so we can thread replies
|
||||
metadata: {
|
||||
whatsAppMessage,
|
||||
thread_ts: event.message?.ts,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```tsx replyModalSubmission.tsx
|
||||
//this trigger sends the reply from Slack to WhatsApp
|
||||
new Trigger({
|
||||
id: "whatsapp-composed-slack-message",
|
||||
name: "WhatsApp: send message from Slack",
|
||||
on: slack.events.viewSubmissionInteraction({
|
||||
callbackId: "submit-message",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
await ctx.logger.info("Modal submission", { event });
|
||||
|
||||
//the message from the input field in Slack
|
||||
const usersResponse = event.view.state?.values.messageField.message
|
||||
.value as string;
|
||||
|
||||
//get the data from the previous messages/panels
|
||||
const privateMetadata =
|
||||
event.view.private_metadata && JSON.parse(event.view.private_metadata);
|
||||
await ctx.logger.info("Private metadata", privateMetadata);
|
||||
const whatsAppMessage = privateMetadata?.whatsAppMessage;
|
||||
|
||||
if (!whatsAppMessage || !usersResponse) {
|
||||
await ctx.logger.error("No message or original message", { event });
|
||||
return;
|
||||
}
|
||||
|
||||
//send WhatsApp message
|
||||
await sendText("send-whatsapp", {
|
||||
fromId: "102119172798864",
|
||||
to: whatsAppMessage.from,
|
||||
text: usersResponse,
|
||||
});
|
||||
|
||||
//send message in the Slack thread
|
||||
await slack.postMessage("slack-reply", {
|
||||
channelName: "whatsapp-support",
|
||||
text: `Replied with: ${usersResponse}`,
|
||||
blocks: JSXSlack(
|
||||
<Blocks>
|
||||
<Header>Reply from @{event.user.username}</Header>
|
||||
<Context>
|
||||
At: {dateFormatter.format(new Date())} To: {whatsAppMessage.from}
|
||||
</Context>
|
||||
<Section>{usersResponse}</Section>
|
||||
</Blocks>
|
||||
),
|
||||
thread_ts: privateMetadata?.thread_ts,
|
||||
});
|
||||
|
||||
return event;
|
||||
},
|
||||
}).listen();
|
||||
|
||||
//creates different Slack blocks depending on the type of WhatsApp message (e.g. text, image, etc.)
|
||||
async function createMessageBody(message: MessageEventMessage) {
|
||||
switch (message.type) {
|
||||
case "text": {
|
||||
return <Section>{message.text.body}</Section>;
|
||||
}
|
||||
case "image": {
|
||||
const mediaUrl = await getMediaUrl(`getImageUrl`, message.image);
|
||||
return (
|
||||
<Image
|
||||
src={mediaUrl}
|
||||
alt={message.image.caption ?? ""}
|
||||
title={message.image.caption ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "video": {
|
||||
const mediaUrl = await getMediaUrl(`getVideoUrl`, message.video);
|
||||
return <Section>{mediaUrl}</Section>;
|
||||
}
|
||||
default:
|
||||
return <Section>Unsupported message type: {message.type}</Section>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: "Send Audio Message"
|
||||
sidebarTitle: "Audio Message"
|
||||
description: "Post a audio message to WhatsApp"
|
||||
---
|
||||
|
||||
Send WhatsApp audio messages from your WhatsApp Business Account.
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="message" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="fromId" type="string" required={false}>
|
||||
The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="to" type="string" required={false}>
|
||||
The phone number you want to send the message to.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="url" type="string" required={true}>
|
||||
The url of the audio file to be sent.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="isReplyTo" type="string" required={false}>
|
||||
Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="contacts" type="object">
|
||||
The contact information of who received the message
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messages" type="object">
|
||||
An array of message ids that were sent
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messaging_product" type="string">
|
||||
Always `whatsapp`
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Notify Slack on New GitHub Star
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Automated reply to WhatsApp messages
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendAudio } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//send an automatic audio reply to the message
|
||||
const audioResponse = await sendAudio("audio", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
url: "https://ssl.gstatic.com/dictionary/static/pronunciation/2022-03-02/audio/wi/wikipedia_en_gb_1.mp3",
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```json Example response
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"input": "12345678901",
|
||||
"wa_id": "12345678901"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
],
|
||||
"messaging_product": "whatsapp"
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: "Send a Contacts Message"
|
||||
sidebarTitle: "Contacts Message"
|
||||
description: "Post a contacts message to WhatsApp"
|
||||
---
|
||||
|
||||
Send WhatsApp contacts messages from your WhatsApp Business Account.
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="message" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="fromId" type="string" required={false}>
|
||||
The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="to" type="string" required={false}>
|
||||
The phone number you want to send the message to.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="contacts" type="object" required={true}>
|
||||
An array of contacts to be sent.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="contacts" type="object">
|
||||
The contact information of who received the message
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messages" type="object">
|
||||
An array of message ids that were sent
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messaging_product" type="string">
|
||||
Always `whatsapp`
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Notify Slack on New GitHub Star
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Automated reply to WhatsApp messages
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendVideo } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//send an contacts message
|
||||
const contactsResponse = await sendContacts("contacts", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
contacts: [
|
||||
{
|
||||
name: {
|
||||
first_name: "Matt",
|
||||
last_name: "Aitken",
|
||||
formatted_name: "Matt Aitken",
|
||||
},
|
||||
phones: [
|
||||
{
|
||||
phone: "+12345678901",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```json Example response
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"input": "12345678901",
|
||||
"wa_id": "12345678901"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
],
|
||||
"messaging_product": "whatsapp"
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: "Send Document Message"
|
||||
sidebarTitle: "Document Message"
|
||||
description: "Post a document message to WhatsApp"
|
||||
---
|
||||
|
||||
Send WhatsApp document messages from your WhatsApp Business Account.
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="message" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="fromId" type="string" required={false}>
|
||||
The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="to" type="string" required={false}>
|
||||
The phone number you want to send the message to.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="url" type="string" required={true}>
|
||||
The url of the document to be sent.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="caption" type="string" required={false}>
|
||||
Optional caption that sits below the video
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="isReplyTo" type="string" required={false}>
|
||||
Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="contacts" type="object">
|
||||
The contact information of who received the message
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messages" type="object">
|
||||
An array of message ids that were sent
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messaging_product" type="string">
|
||||
Always `whatsapp`
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Notify Slack on New GitHub Star
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Automated reply to WhatsApp messages
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendDocument } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//send an automatic document reply to the message
|
||||
const documentResponse = await sendDocument("doc", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
url: "https://upload.wikimedia.org/wikipedia/commons/2/20/Re_example.pdf",
|
||||
caption: "A pdf for you",
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```json Example response
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"input": "12345678901",
|
||||
"wa_id": "12345678901"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
],
|
||||
"messaging_product": "whatsapp"
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: "Send Image Message"
|
||||
sidebarTitle: "Image Message"
|
||||
description: "Post a image message to WhatsApp"
|
||||
---
|
||||
|
||||
Send WhatsApp image messages from your WhatsApp Business Account.
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="message" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="fromId" type="string" required={false}>
|
||||
The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="to" type="string" required={false}>
|
||||
The phone number you want to send the message to.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="url" type="string" required={true}>
|
||||
The url of the image to be sent.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="caption" type="string" required={false}>
|
||||
Optional caption that sits below the video
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="isReplyTo" type="string" required={false}>
|
||||
Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="contacts" type="object">
|
||||
The contact information of who received the message
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messages" type="object">
|
||||
An array of message ids that were sent
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messaging_product" type="string">
|
||||
Always `whatsapp`
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Notify Slack on New GitHub Star
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Automated reply to WhatsApp messages
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendImage } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//send an automatic image reply to the message
|
||||
const imageResponse = await sendImage("image", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
url: "https://app.trigger.dev/emails/logo.png",
|
||||
caption: "This is a great caption",
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```json Example response
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"input": "12345678901",
|
||||
"wa_id": "12345678901"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
],
|
||||
"messaging_product": "whatsapp"
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
title: "Send a Location Message"
|
||||
sidebarTitle: "Location Message"
|
||||
description: "Post a location message to WhatsApp"
|
||||
---
|
||||
|
||||
Send WhatsApp location messages from your WhatsApp Business Account.
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="message" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="fromId" type="string" required={false}>
|
||||
The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="to" type="string" required={false}>
|
||||
The phone number you want to send the message to.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="latitude" type="number" required={true}>
|
||||
The latitude of the location to be sent.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="longitude" type="number" required={true}>
|
||||
The latitude of the location to be sent.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="name" type="string" required={false}>
|
||||
Name of the location. An address must be specified for this to appear.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="address" type="string" required={false}>
|
||||
The address of the location, in a single string.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="isReplyTo" type="string" required={false}>
|
||||
Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="contacts" type="object">
|
||||
The contact information of who received the message
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messages" type="object">
|
||||
An array of message ids that were sent
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messaging_product" type="string">
|
||||
Always `whatsapp`
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Notify Slack on New GitHub Star
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Automated reply to WhatsApp messages
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendVideo } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//send an location message
|
||||
const locationResponse = await sendLocation("location", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
latitude: 37.422,
|
||||
longitude: -122.084,
|
||||
name: "Trigger.dev HQ",
|
||||
address: "123 Main St, San Francisco, CA 94105",
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```json Example response
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"input": "12345678901",
|
||||
"wa_id": "12345678901"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
],
|
||||
"messaging_product": "whatsapp"
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: "Send a Reaction Message"
|
||||
sidebarTitle: "Reaction Message"
|
||||
description: "Post a reaction to a WhatsApp message"
|
||||
---
|
||||
|
||||
Send WhatsApp reaction messages from your WhatsApp Business Account.
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="message" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="fromId" type="string" required={false}>
|
||||
The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="to" type="string" required={false}>
|
||||
The phone number you want to send the message to.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="emoji" type="string" required={true}>
|
||||
The emoji you want to react to the message with 🔥
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="isReplyTo" type="string" required={true}>
|
||||
This is required: add the id of the message you want to reply to.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="contacts" type="object">
|
||||
The contact information of who received the message
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messages" type="object">
|
||||
An array of message ids that were sent
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messaging_product" type="string">
|
||||
Always `whatsapp`
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Notify Slack on New GitHub Star
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Automated reply to WhatsApp messages
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendVideo } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//send an automatic reaction to the message
|
||||
const reactionResponse = await sendReaction("reaction", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
isReplyTo: event.message.id,
|
||||
emoji: "🔥",
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```json Example response
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"input": "12345678901",
|
||||
"wa_id": "12345678901"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
],
|
||||
"messaging_product": "whatsapp"
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: "Send a Sticker Message"
|
||||
sidebarTitle: "Sticker Message"
|
||||
description: "Post a sticker message to WhatsApp"
|
||||
---
|
||||
|
||||
Send WhatsApp sticker messages from your WhatsApp Business Account.
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="message" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="fromId" type="string" required={false}>
|
||||
The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="to" type="string" required={false}>
|
||||
The phone number you want to send the message to.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="url" type="string" required={true}>
|
||||
The url of the image to be sent. Please note that only webp files will work for stickers.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="caption" type="string" required={false}>
|
||||
Optional caption that sits below the video
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="isReplyTo" type="string" required={false}>
|
||||
Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="contacts" type="object">
|
||||
The contact information of who received the message
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messages" type="object">
|
||||
An array of message ids that were sent
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messaging_product" type="string">
|
||||
Always `whatsapp`
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Notify Slack on New GitHub Star
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Automated reply to WhatsApp messages
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendVideo } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//send an automatic sticker to the message
|
||||
const stickerResponse = await sendSticker("stick", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
url: "https://www.tyntec.com/sites/default/files/2020-07/tyntec_rocket_sticker_512px_001_.webp",
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```json Example response
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"input": "12345678901",
|
||||
"wa_id": "12345678901"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
],
|
||||
"messaging_product": "whatsapp"
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
title: "Send Template Message"
|
||||
sidebarTitle: "Template Message"
|
||||
description: "Post a template message to WhatsApp"
|
||||
---
|
||||
|
||||
Send WhatsApp template messages from your WhatsApp Business Account.
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="message" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="fromId" type="string" required={false}>
|
||||
The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="to" type="string" required={false}>
|
||||
The phone number you want to send the message to.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="template" type="string" required={true}>
|
||||
The name of the template you want to send. Note that these have to be approved by Meta before they can be used. You can find this in the your WhatsApp Business dashboard.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="languageCode" type="string" required={true}>
|
||||
The language code of the template you want to send, e.g. "en_US". You can find this in the your WhatsApp Business dashboard.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="parameters" type="object" required={false}>
|
||||
You can customise the message by providing values for variables in the template. There are three sections header, body and buttons. [Full WhatsApp documentation](https://developers.facebook.com/micro_site/url/?click_from_context_menu=true&country=GB&destination=https%3A%2F%2Fdevelopers.facebook.com%2Fdocs%2Fwhatsapp%2Fapi%2Fmessages%2Fmessage-templates%2F%23local&event_type=click&last_nav_impression_id=1gi9JORcnblgd3gKs&max_percent_page_viewed=52&max_viewport_height_px=1272&max_viewport_width_px=2560&orig_http_referrer=https%3A%2F%2Fdevelopers.facebook.com%2Fdocs%2Fwhatsapp%2Fapi%2Fmessages%2Fmessage-templates%2F&orig_request_uri=https%3A%2F%2Fdevelopers.facebook.com%2Fajax%2Fdocs%2Fnav%2F%3Fpath1%3Dwhatsapp%26path2%3Dapi%26path3%3Dmessages%26path4%3Dmessage-templates®ion=emea&scrolled=true&session_id=1p7ElKefs7QDpKC36&site=developers)
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="contacts" type="object">
|
||||
The contact information of who received the message
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messages" type="object">
|
||||
An array of message ids that were sent
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messaging_product" type="string">
|
||||
Always `whatsapp`
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Notify Slack on New GitHub Star
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Automated reply to WhatsApp messages
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendText } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//send a templated message, with variables
|
||||
//in this example we have included are the possible variables (mostly commented out)
|
||||
const templateResponse = await sendTemplate("template-msg", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
//this template must be approved in your WhatsApp Business Account
|
||||
template: "hello_world",
|
||||
languageCode: "en_US",
|
||||
//you only need to include these if your template has variables
|
||||
parameters: {
|
||||
//only include this if your header has variables, they can be text, image, video or document
|
||||
header: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Matt",
|
||||
},
|
||||
// {
|
||||
// type: "image",
|
||||
// image: {
|
||||
// link: "https://app.trigger.dev/emails/logo.png",
|
||||
// caption: "This is a logo",
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// type: "video",
|
||||
// video: {
|
||||
// link: "https://media.giphy.com/media/5i7umUqAOYYEw/giphy.mp4",
|
||||
// caption: "This is a fun video",
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// type: "document",
|
||||
// video: {
|
||||
// link: "https://upload.wikimedia.org/wikipedia/commons/2/20/Re_example.pdf",
|
||||
// caption: "This is a document",
|
||||
// },
|
||||
// },
|
||||
],
|
||||
//only include this if the body of your message has variables. They can be text, currency, date_time
|
||||
body: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Matt",
|
||||
},
|
||||
// {
|
||||
// type: "currency",
|
||||
// currency: {
|
||||
// amount_1000: 1000,
|
||||
// code: "USD",
|
||||
// fallback_value: "1000 USD",
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// type: "date_time",
|
||||
// date_time: {
|
||||
// fallback_value: "2021-01-01",
|
||||
// },
|
||||
// }
|
||||
],
|
||||
//only include this if the buttons of your message have variables. They can be quick_reply or call_to_action
|
||||
buttons: [
|
||||
{
|
||||
sub_type: "quick_reply",
|
||||
parameters: [
|
||||
{
|
||||
type: "payload",
|
||||
payload: "buy",
|
||||
},
|
||||
// {
|
||||
// type: "text",
|
||||
// text: "Buy now!",
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
sub_type: "call_to_action",
|
||||
parameters: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Buy now!",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```json Example response
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"input": "12345678901",
|
||||
"wa_id": "12345678901"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
],
|
||||
"messaging_product": "whatsapp"
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: "Send Text Message"
|
||||
sidebarTitle: "Text Message"
|
||||
description: "Post a text message to WhatsApp"
|
||||
---
|
||||
|
||||
Send WhatsApp text messages from your WhatsApp Business Account.
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="message" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="fromId" type="string" required={false}>
|
||||
The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="to" type="string" required={false}>
|
||||
The phone number you want to send the message to.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="text" type="string" required={true}>
|
||||
The text of the message to be sent.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="isReplyTo" type="string" required={false}>
|
||||
Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="contacts" type="object">
|
||||
The contact information of who received the message
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messages" type="object">
|
||||
An array of message ids that were sent
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messaging_product" type="string">
|
||||
Always `whatsapp`
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Notify Slack on New GitHub Star
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Automated reply to WhatsApp messages
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendText } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//send an automatic reply to the message
|
||||
const textResponse = await sendText("text-msg", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
text: "Hello! This is a text sent automatically from https://www.trigger.dev",
|
||||
//this is optional – if set, the message will appear quoting the original message
|
||||
isReplyTo: event.message.id,
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```json Example response
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"input": "12345678901",
|
||||
"wa_id": "12345678901"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
],
|
||||
"messaging_product": "whatsapp"
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: "Send Video Message"
|
||||
sidebarTitle: "Video Message"
|
||||
description: "Post a video message to WhatsApp"
|
||||
---
|
||||
|
||||
Send WhatsApp video messages from your WhatsApp Business Account.
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="key" type="string" required={true}>
|
||||
A unique string. Please see the [Keys and Resumability](/guides/resumability)
|
||||
doc for more info.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="message" type="object" required={true}>
|
||||
<Expandable title="properties" defaultOpen={true}>
|
||||
<ParamField path="fromId" type="string" required={false}>
|
||||
The id of the phone number you want to send the message from. You can find this in the your WhatsApp Business dashboard. Note, this is not the phone number.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="to" type="string" required={false}>
|
||||
The phone number you want to send the message to.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="url" type="string" required={true}>
|
||||
The url of the video to be sent.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="caption" type="string" required={false}>
|
||||
Optional caption that sits below the video
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="isReplyTo" type="string" required={false}>
|
||||
Optionally, add the id of the message you want to reply to. It will appear as a reply in the WhatsApp chat.
|
||||
</ParamField>
|
||||
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Response
|
||||
|
||||
<ResponseField name="contacts" type="object">
|
||||
The contact information of who received the message
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messages" type="object">
|
||||
An array of message ids that were sent
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="messaging_product" type="string">
|
||||
Always `whatsapp`
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Notify Slack on New GitHub Star
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript Automated reply to WhatsApp messages
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendVideo } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//send an automatic video reply to the message
|
||||
const videoResponse = await sendVideo("video", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
url: "https://media.giphy.com/media/5i7umUqAOYYEw/giphy.mp4",
|
||||
caption: "OMGGGG CATTTT",
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
```json Example response
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"input": "12345678901",
|
||||
"wa_id": "12345678901"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
],
|
||||
"messaging_product": "whatsapp"
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "Message Event"
|
||||
sidebarTitle: "Message"
|
||||
description: "Trigger a workflow whenever a WhatsApp message is received in your WhatsApp Business Account"
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//your workflow code here
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
## Params
|
||||
|
||||
<ParamField path="accountId" type="string" required={true}>
|
||||
Your WhatsApp Business Account id
|
||||
</ParamField>
|
||||
|
||||
## Event
|
||||
|
||||
<ResponseField name="message" type="object" required={true}>
|
||||
The message object has the type of message (e.g. text, video, audio etc) and
|
||||
the message content.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="contacts" type="object" required={true}>
|
||||
The contact information of the sender.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="metadata" type="object" required={true}>
|
||||
Information about the account and phone number that received the message, i.e.
|
||||
your WhatsApp Business Account details.
|
||||
</ResponseField>
|
||||
|
||||
## Example Workflows
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript post a message to your support Slack channel
|
||||
import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "demo",
|
||||
on: events.messageEvent({
|
||||
accountId: "<account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//get the user from your database, using the phone number
|
||||
const user = await db.getUser({ phoneNumber: event.message.from });
|
||||
|
||||
if (!user) {
|
||||
await ctx.logger.error(`No user to phone number ${event.message.from}`);
|
||||
return;
|
||||
}
|
||||
|
||||
//post a message to your support Slack channel
|
||||
await slack.postMessage({
|
||||
channel: "support",
|
||||
text: `Support message from ${user.name} (${user.email})\n${event.message.body}`,
|
||||
});
|
||||
},
|
||||
}).listen();
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Example Event payload
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"message": {
|
||||
"id": "wamid.ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
"from": "12345678901",
|
||||
"text": {
|
||||
"body": "The quick brown fox jumps over the lazy dog"
|
||||
},
|
||||
"type": "text",
|
||||
"timestamp": 1675257050000
|
||||
},
|
||||
"contacts": [
|
||||
{
|
||||
"wa_id": "12345678901",
|
||||
"profile": {
|
||||
"name": "Matt Aitken"
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"phone_number_id": "987654321012",
|
||||
"display_phone_number": "15550676999"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "WhatsApp reference"
|
||||
sidebarTitle: "Reference"
|
||||
description: "Reference for the WhatsApp integration"
|
||||
---
|
||||
|
||||
Navigate through the following pages for the full reference for the WhatsApp integration.
|
||||
|
||||
Or [view the examples](/examples/whatsapp) of how to use the WhatsApp integration.
|
||||
+68
-6
@@ -52,17 +52,20 @@
|
||||
"pages": [
|
||||
"welcome",
|
||||
"getting-started",
|
||||
"get-help"
|
||||
"get-help",
|
||||
"viewing-runs",
|
||||
"changelog"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Examples",
|
||||
"group": "Example workflows",
|
||||
"pages": [
|
||||
"examples/examples",
|
||||
"examples/slack",
|
||||
"examples/github",
|
||||
"examples/resend",
|
||||
"examples/shopify",
|
||||
"examples/resend"
|
||||
"examples/slack",
|
||||
"examples/whatsapp"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -79,10 +82,30 @@
|
||||
{
|
||||
"group": "APIs",
|
||||
"pages": [
|
||||
{
|
||||
"group": "GitHub",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Events",
|
||||
"pages": [
|
||||
"integrations/apis/github/events/issue-comments",
|
||||
"integrations/apis/github/events/issues",
|
||||
"integrations/apis/github/events/new-star",
|
||||
"integrations/apis/github/events/pull-request-comments",
|
||||
"integrations/apis/github/events/pull-request-reviews",
|
||||
"integrations/apis/github/events/pull-requests",
|
||||
"integrations/apis/github/events/push"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Slack",
|
||||
"pages": [
|
||||
"integrations/apis/slack/actions/post-message"
|
||||
"integrations/apis/slack/actions/post-message",
|
||||
"integrations/apis/slack/actions/post-message-response",
|
||||
"integrations/apis/slack/actions/add-reaction",
|
||||
"integrations/apis/slack/actions/open-view"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -90,6 +113,32 @@
|
||||
"pages": [
|
||||
"integrations/apis/resend/actions/send-email"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "WhatsApp",
|
||||
"pages": [
|
||||
"integrations/apis/whatsapp/index",
|
||||
{
|
||||
"group": "Events",
|
||||
"pages": [
|
||||
"integrations/apis/whatsapp/events/message"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Actions",
|
||||
"pages": [
|
||||
"integrations/apis/whatsapp/actions/text-message",
|
||||
"integrations/apis/whatsapp/actions/reaction-message",
|
||||
"integrations/apis/whatsapp/actions/audio-message",
|
||||
"integrations/apis/whatsapp/actions/video-message",
|
||||
"integrations/apis/whatsapp/actions/document-message",
|
||||
"integrations/apis/whatsapp/actions/sticker-message",
|
||||
"integrations/apis/whatsapp/actions/template-message",
|
||||
"integrations/apis/whatsapp/actions/location-message",
|
||||
"integrations/apis/whatsapp/actions/contacts-message"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -103,6 +152,7 @@
|
||||
"functions/logging",
|
||||
"functions/delays",
|
||||
"functions/send-event",
|
||||
"functions/run-once",
|
||||
"functions/loops-conditionals-etc"
|
||||
]
|
||||
},
|
||||
@@ -128,12 +178,19 @@
|
||||
{
|
||||
"group": "API Reference",
|
||||
"pages": [
|
||||
"api/events/sendEvent"
|
||||
"api-reference/events/sendEvent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Webhook Catalog",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Slack",
|
||||
"pages": [
|
||||
"integrations/apis/slack/events/block-action-interaction",
|
||||
"integrations/apis/slack/events/view-submission-interaction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "GitHub",
|
||||
"pages": [
|
||||
@@ -158,5 +215,10 @@
|
||||
"auth": {
|
||||
"method": "bearer"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"posthog": {
|
||||
"apiKey": "phc_cU8Ow5tDcy9alp09yWYpad98CHafOsfsXK3HzLM6uy0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,6 @@
|
||||
"dev": "mintlify dev --port 3050"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mintlify": "^2.0.16"
|
||||
"mintlify": "2.0.16"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: "Roadmap"
|
||||
description: "Coming changes to trigger.dev"
|
||||
---
|
||||
|
||||
## Next up
|
||||
|
||||
Features and integrations we'll be working on next.
|
||||
|
||||
#### Integrations
|
||||
|
||||
- Airtable
|
||||
- Slack: add support for commands and triggering on message events. Add actions for channel and team management.
|
||||
- Stripe
|
||||
- Google Sheets
|
||||
- Twitter
|
||||
|
||||
#### Features
|
||||
|
||||
- Unified workflow run list. See all your runs across all your workflows in one place.
|
||||
- Live logs. See your workflow logs in real time.
|
||||
- Workflow presence. See if your workflow is connected and running.
|
||||
- Error alerts. Get notified when your workflow fails or doesn't start.
|
||||
|
||||
#### Platform
|
||||
|
||||
- More robust and resilient webhook handling. We're working on a new webhook ingest system that will ensure webhook requests are never missed.
|
||||
@@ -50,6 +50,9 @@ Once you've connected to Trigger.dev, and authorized your GitHub account, we'll
|
||||
If we haven't built out the integration you need, you can use our `webhookEvent` function to create a webhook subscription. You'll need to register the webhook yourself, but we'll take care of the rest. Here's an example for triggering events when a new booking happens in [Cal.com](https://cal.com):
|
||||
|
||||
```ts
|
||||
import { Trigger, webhookEvent } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
new Trigger({
|
||||
id: "caldotcom-to-slack",
|
||||
name: "Cal.com To Slack",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: "Viewing runs"
|
||||
sidebarTitle: "Viewing runs"
|
||||
description: "Full visibility of every run your workflow has made."
|
||||
---
|
||||
|
||||
One of the most powerful features of Trigger.dev is the runs page. All of the steps in a workflow, including the initial event, can be viewed in detail. See the status / output of each step, the logs, rich previews, errors and much more.
|
||||
|
||||

|
||||
|
||||
+20
-17
@@ -4,25 +4,9 @@ sidebarTitle: "Welcome"
|
||||
description: ""
|
||||
---
|
||||
|
||||
Trigger.dev is an open source platform that enables developers to create event-driven background tasks directly in their code. Build, test and run workflows locally and subscribe to webhooks, schedule jobs, run background jobs and add long delays easily and reliably.
|
||||
Trigger.dev is an open source platform that makes it easy for developers to create event-driven background tasks directly in their code. Build, test and run workflows locally using our SDK. Subscribe to webhooks, schedule jobs, run background jobs and add long delays easily and reliably. In our web app you get full visibility of every run your workflow has ever made.
|
||||
|
||||
Workflows live in your codebase so you can use your existing types, functions, version control and IDE. They are triggered by us but run on your server so your private data is never exposed.
|
||||
|
||||
Go to [trigger.dev](https://app.trigger.dev) and sign up or login to your account.
|
||||
|
||||
_A workflow in action:_
|
||||

|
||||
|
||||
<iframe
|
||||
width="100%"
|
||||
height="440"
|
||||
src="https://www.youtube.com/embed/aFlwD0frvnQ?rel=0"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
<CardGroup>
|
||||
<Card title="Getting Started" icon="circle-play" href="/getting-started">
|
||||
Quickly get up and running with Trigger.dev by following our quick start
|
||||
@@ -53,3 +37,22 @@ _A workflow in action:_
|
||||
API integrations for you and help you with workflows.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
|
||||
Workflows live in your codebase so you can use your existing types, functions, version control and IDE. They are triggered by us but run on your server so your private data is never exposed.
|
||||
|
||||
|
||||
_A workflow in action:_
|
||||

|
||||
|
||||
<iframe
|
||||
width="100%"
|
||||
height="440"
|
||||
src="https://www.youtube.com/embed/aFlwD0frvnQ?rel=0"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM node:16-bullseye AS pruner
|
||||
RUN apt-get update && apt-get install openssl -y
|
||||
WORKDIR /app
|
||||
RUN npm install turbo -g
|
||||
RUN npm install turbo@1.7.0 -g
|
||||
COPY . .
|
||||
RUN turbo prune --scope=webapp --docker
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
@@ -21,7 +21,7 @@ COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
FROM base AS production-deps
|
||||
WORKDIR /app
|
||||
RUN npm install turbo -g
|
||||
RUN npm install turbo@1.7.0 -g
|
||||
RUN corepack enable
|
||||
ENV NODE_ENV production
|
||||
RUN npm config set python /usr/bin/python3
|
||||
@@ -31,7 +31,7 @@ RUN pnpx prisma generate --schema /app/apps/webapp/prisma/schema.prisma
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
RUN npm install turbo -g
|
||||
RUN npm install turbo@1.7.0 -g
|
||||
COPY turbo.json turbo.json
|
||||
RUN corepack enable
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
@@ -1,150 +1,73 @@
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
|
||||
import type { IntegrationMetadata } from "@trigger.dev/integration-sdk";
|
||||
import { PopupButton } from "@typeform/embed-react";
|
||||
import { ApiLogoIcon } from "~/components/code/ApiLogoIcon";
|
||||
import discord from "../assets/images/discord.png";
|
||||
import onboarding from "../assets/images/onboarding-image.png";
|
||||
import { Panel } from "./layout/Panel";
|
||||
import { PrimaryA, SecondaryA } from "./primitives/Buttons";
|
||||
import { Body } from "./primitives/text/Body";
|
||||
import { Underlined, UnderlinedList } from "~/components/primitives/Tabs";
|
||||
import { useCurrentOrganization } from "~/hooks/useOrganizations";
|
||||
import CodeBlock from "./code/CodeBlock";
|
||||
import { PrimaryLink, SecondaryA } from "./primitives/Buttons";
|
||||
import { SubTitle } from "./primitives/text/SubTitle";
|
||||
|
||||
export function CreateNewWorkflow() {
|
||||
const currentOrganization = useCurrentOrganization();
|
||||
if (currentOrganization === undefined) {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SubTitle>Create a new workflow</SubTitle>
|
||||
<div className="flex gap-2">
|
||||
<PrimaryA
|
||||
<PrimaryLink
|
||||
to={`/orgs/${currentOrganization.slug}/workflows/new`}
|
||||
rel="noreferrer"
|
||||
>
|
||||
Create a workflow
|
||||
</PrimaryLink>
|
||||
<SecondaryA
|
||||
href="https://docs.trigger.dev"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="w-4 h-4" />
|
||||
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
|
||||
<span>Documentation</span>
|
||||
</PrimaryA>
|
||||
<SecondaryA
|
||||
href="https://docs.trigger.dev/examples/examples"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="w-4 h-4" />
|
||||
<span>Example Workflows</span>
|
||||
</SecondaryA>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateNewWorkflowNoWorkflows({
|
||||
providers,
|
||||
}: {
|
||||
providers: IntegrationMetadata[];
|
||||
}) {
|
||||
export function InstallPackages({ packages }: { packages: string }) {
|
||||
return (
|
||||
<>
|
||||
<Panel className="flex p-0 overflow-hidden">
|
||||
<div className="flex flex-col p-6 w-full xl:w-2/3 text-slate-300 ">
|
||||
<Body className="mb-5 max-w-max px-3.5 py-2 bg-slate-700 rounded border border-slate-600">
|
||||
Trigger.dev workflows are written in your own codebase and run in
|
||||
your existing infrastructure.
|
||||
</Body>
|
||||
<Body size="small" className={allCapsTitleClasses}>
|
||||
To get started
|
||||
</Body>
|
||||
<ol className="flex flex-col gap-2 list-decimal marker:text-slate-400 ml-5 mb-5">
|
||||
<li>
|
||||
Check out the Quick Start Guide to create your first workflow in
|
||||
your code.
|
||||
</li>
|
||||
<li>
|
||||
Trigger the workflow by writing a test on the Test page. The
|
||||
workflow run will then appear on the Runs page.
|
||||
</li>
|
||||
<li>
|
||||
If you need to authenticate with an API, the Runs page will
|
||||
display a prompt to connect.
|
||||
</li>
|
||||
</ol>
|
||||
<div className="flex gap-2 mb-8">
|
||||
<PrimaryA
|
||||
href="https://docs.trigger.dev/getting-started"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="w-4 h-4" />
|
||||
<span>Quick Start Guide</span>
|
||||
</PrimaryA>
|
||||
<SecondaryA
|
||||
href="https://docs.trigger.dev/examples/examples"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="w-4 h-4" />
|
||||
<span>Example workflows</span>
|
||||
</SecondaryA>
|
||||
</div>
|
||||
<Body size="small" className={allCapsTitleClasses}>
|
||||
API integrations
|
||||
</Body>
|
||||
<Body className="mb-4">
|
||||
Easily authenticate with APIs using the supported integrations
|
||||
below. If there's an integration we don't yet support,{" "}
|
||||
<PopupButton
|
||||
id="VwblgGDZ"
|
||||
className="underline opacity-80 hover:opacity-100 transition underline-offset-2"
|
||||
>
|
||||
<span>vote for it here</span>
|
||||
</PopupButton>{" "}
|
||||
and we'll add it.
|
||||
</Body>
|
||||
<div className="flex gap-2 items-center flex-wrap mb-8">
|
||||
{providers.map((provider) => (
|
||||
<ApiLogoIcon
|
||||
key={provider.slug}
|
||||
integration={provider}
|
||||
size="regular"
|
||||
/>
|
||||
))}
|
||||
<Body className="text-slate-300">+</Body>
|
||||
<Body
|
||||
size="small"
|
||||
className="uppercase text-slate-400 bg-slate-850 py-2.5 px-4 rounded tracking-wide"
|
||||
>
|
||||
Fetch
|
||||
</Body>
|
||||
<Body className="text-slate-300">&</Body>
|
||||
<Body
|
||||
size="small"
|
||||
className="uppercase text-slate-400 bg-slate-850 py-2.5 px-4 rounded tracking-wide"
|
||||
>
|
||||
Webhooks
|
||||
</Body>
|
||||
</div>
|
||||
<Body size="small" className={allCapsTitleClasses}>
|
||||
Join the community
|
||||
</Body>
|
||||
<Body className="mb-4">
|
||||
To get help quickly and answers to any questions, join our Discord.
|
||||
</Body>
|
||||
<PrimaryA
|
||||
href="https://discord.gg/kA47vcd8P6"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img src={discord} alt="Discord" className="h-3.5 -ml-1" />
|
||||
<span>Join Discord</span>
|
||||
</PrimaryA>
|
||||
</div>
|
||||
<div className="hidden xl:flex flex-col w-1/3">
|
||||
<img
|
||||
src={onboarding}
|
||||
alt="Logo"
|
||||
className="w-full h-full object-cover"
|
||||
<Tab.Group>
|
||||
<UnderlinedList>
|
||||
<Underlined>npm</Underlined>
|
||||
<Underlined>pnpm</Underlined>
|
||||
<Underlined>yarn</Underlined>
|
||||
</UnderlinedList>
|
||||
<Tab.Panels className="flex-grow">
|
||||
<Tab.Panel className="relative h-full">
|
||||
<CodeBlock
|
||||
code={`npm install ${packages}`}
|
||||
language="bash"
|
||||
align="top"
|
||||
showLineNumbers={false}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="relative h-full">
|
||||
<CodeBlock
|
||||
code={`pnpm add ${packages}`}
|
||||
language="bash"
|
||||
align="top"
|
||||
showLineNumbers={false}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="relative h-full">
|
||||
<CodeBlock
|
||||
code={`yarn add ${packages}`}
|
||||
language="bash"
|
||||
align="top"
|
||||
showLineNumbers={false}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
}
|
||||
|
||||
const allCapsTitleClasses = "mb-2 uppercase tracking-wide text-slate-400";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TertiaryA } from "./primitives/Buttons";
|
||||
import { Body } from "./primitives/text/Body";
|
||||
|
||||
export function ImpersonationBanner() {
|
||||
return (
|
||||
<div className="grid grid-cols-3 items-center bg-gradient-to-r from-amber-500 via-amber-400 to-amber-500 text-center px-4">
|
||||
<span></span>
|
||||
<Body size="small" className="text-slate-800">
|
||||
You are impersonating{" "}
|
||||
<span className="font-semibold">James Ritchie</span>
|
||||
</Body>
|
||||
<TertiaryA
|
||||
href="/api/auth/logout"
|
||||
className="justify-self-end text-slate-700 hover:!text-slate-900"
|
||||
>
|
||||
Stop impersonating
|
||||
</TertiaryA>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { XMarkIcon, DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||
import { PrimaryA } from "./primitives/Buttons";
|
||||
import { Body } from "./primitives/text/Body";
|
||||
|
||||
export function NoMobileOverlay() {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center sm:hidden">
|
||||
<div className="relative bg-black opacity-90 h-full w-full"></div>
|
||||
<div className="absolute flex flex-col gap-6 mx-8 items-center bg-slate-850 px-8 py-10 text-center rounded-lg">
|
||||
<XMarkIcon className="absolute top-14 h-8 w-8 text-rose-600" />
|
||||
<DevicePhoneMobileIcon className="h-16 w-16 text-slate-500" />
|
||||
<Body className="text-slate-300">
|
||||
Trigger.dev is currently only available on desktop.
|
||||
</Body>
|
||||
<PrimaryA href="https://trigger.dev">Back Home</PrimaryA>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/components/prism-typescript";
|
||||
import "prismjs/components/prism-json";
|
||||
import "prismjs/components/prism-bash";
|
||||
import "prismjs/plugins/line-numbers/prism-line-numbers";
|
||||
import "prismjs/plugins/line-numbers/prism-line-numbers.css";
|
||||
import { CopyTextButton } from "../CopyTextButton";
|
||||
@@ -11,7 +12,7 @@ Prism.manual = true;
|
||||
|
||||
type CodeBlockProps = {
|
||||
code: string;
|
||||
language?: "typescript" | "json";
|
||||
language?: "typescript" | "json" | "bash";
|
||||
showCopyButton?: boolean;
|
||||
align?: "top" | "center";
|
||||
maxHeight?: string;
|
||||
@@ -60,7 +61,7 @@ export default function CodeBlock({
|
||||
{showCopyButton === true && (
|
||||
<CopyTextButton
|
||||
className={classNames(
|
||||
"absolute text-sm my-2 mx-2",
|
||||
"absolute my-2 mx-2 text-sm",
|
||||
align === "center" ? " top-1/2 right-0" : "top-0 right-0"
|
||||
)}
|
||||
value={code}
|
||||
@@ -68,9 +69,9 @@ export default function CodeBlock({
|
||||
/>
|
||||
)}
|
||||
{maxHeight && (
|
||||
<div className="absolute left-0 bottom-0 w-full flex items-center justify-center bg-gradient-to-b from-transparent to-[#0F172A]">
|
||||
<div className="absolute left-0 bottom-0 flex w-full items-center justify-center bg-gradient-to-b from-transparent to-[#0F172A]">
|
||||
<button
|
||||
className="bg-slate-800 rounded-full py-2 px-3.5 text-xs mb-1 hover:bg-slate-700 transition"
|
||||
className="mb-1 rounded-full bg-slate-800 py-2 px-3.5 text-xs transition hover:bg-slate-700"
|
||||
onClick={(e) => setIsCollapsed((s) => !s)}
|
||||
>
|
||||
{isCollapsed ? "Expand" : "Collapse"}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const inlineCode =
|
||||
"px-1 py-0.5 text-sm bg-slate-700 border border-slate-900 rounded text-slate-200";
|
||||
|
||||
export function InlineCode({ children }: { children: ReactNode }) {
|
||||
return <code className={inlineCode}>{children}</code>;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export function BasicConnectButton({
|
||||
organizationId={organizationId}
|
||||
sourceId={sourceId}
|
||||
serviceId={serviceId}
|
||||
className="flex rounded bg-indigo-700 gap-3 text-sm text-white items-center hover:bg-indigo-600 transition shadow-md disabled:opacity-50 pl-3 pr-4 py-2"
|
||||
className="flex items-center gap-3 rounded bg-indigo-700 py-2 pl-3 pr-4 text-sm text-white shadow-md transition hover:bg-indigo-600 disabled:opacity-50"
|
||||
>
|
||||
{(status) => (
|
||||
<>
|
||||
|
||||
@@ -28,7 +28,11 @@ export function ConnectOAuthButton({
|
||||
const { createFetcher, status } = useCreateConnection(sourceId, serviceId);
|
||||
|
||||
return (
|
||||
<createFetcher.Form method="post" action="/resources/connection?index">
|
||||
<createFetcher.Form
|
||||
method="post"
|
||||
action="/resources/connection?index"
|
||||
className="flex"
|
||||
>
|
||||
<input type="hidden" name="type" value="oauth" />
|
||||
<input type="hidden" name="organizationId" value={organizationId} />
|
||||
<input type="hidden" name="service" value={integration.slug} />
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import classNames from "classnames";
|
||||
import { ImpersonationBanner } from "../ImpersonationBanner";
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-rows-[3rem_auto_2rem] w-full h-full">
|
||||
{children}
|
||||
</div>
|
||||
// <div className="grid grid-rows-[2rem_3rem_auto_2rem] w-full h-full">
|
||||
// <ImpersonationBanner />
|
||||
// {children}
|
||||
// </div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InformationCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { Body } from "../primitives/text/Body";
|
||||
|
||||
export type PanelInfoProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -8,15 +9,20 @@ export type PanelInfoProps = {
|
||||
export function PanelInfo({
|
||||
children,
|
||||
className,
|
||||
message,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
message?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-2.5 items-start bg-slate-400/10 border border-slate-600 w-full shadow-md rounded-md p-3 ${className}`}
|
||||
className={`flex w-full justify-between gap-4 rounded-md border border-slate-600 bg-slate-400/10 py-3 pl-3 pr-3 shadow-md ${className}`}
|
||||
>
|
||||
<InformationCircleIcon className="h-6 w-6 min-w-[24px] text-blue-500" />
|
||||
<div className="flex items-center justify-start gap-2.5">
|
||||
<InformationCircleIcon className="h-6 w-6 min-w-[24px] text-blue-500" />
|
||||
<Body className="text-slate-300">{message}</Body>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import { Body } from "../primitives/text/Body";
|
||||
|
||||
export type PanelWarningProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -8,15 +9,20 @@ export type PanelWarningProps = {
|
||||
export function PanelWarning({
|
||||
children,
|
||||
className,
|
||||
message,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-2.5 items-center bg-amber-400/10 border border-amber-500 w-full shadow-md rounded-md p-3 ${className}`}
|
||||
className={`flex w-full items-center rounded-md border border-amber-500 bg-amber-400/10 p-3 shadow-md ${className}`}
|
||||
>
|
||||
<ExclamationTriangleIcon className="h-6 w-6 min-w-[24px] text-amber-500" />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 min-w-[24px] text-amber-500" />
|
||||
<Body className="">{message}</Body>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ClipboardDocumentCheckIcon,
|
||||
Squares2X2Icon,
|
||||
Cog6ToothIcon,
|
||||
PlusCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Link, NavLink } from "@remix-run/react";
|
||||
import {
|
||||
@@ -24,7 +25,7 @@ import invariant from "tiny-invariant";
|
||||
import { CopyText } from "../CopyText";
|
||||
|
||||
export function SideMenuContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-[300px_2fr] h-full">{children}</div>;
|
||||
return <div className="grid h-full grid-cols-[300px_2fr]">{children}</div>;
|
||||
}
|
||||
|
||||
type SideMenuItem = {
|
||||
@@ -59,6 +60,11 @@ export function OrganizationsSideMenu() {
|
||||
icon: <UsersIcon className={iconStyle} />,
|
||||
to: `/orgs/${currentOrganization.slug}/members`,
|
||||
},
|
||||
{
|
||||
name: "New Workflow",
|
||||
icon: <PlusCircleIcon className={iconStyle} />,
|
||||
to: `/orgs/${currentOrganization.slug}/workflows/new`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -129,24 +135,24 @@ function SideMenu({
|
||||
invariant(organization, "Organization must be defined");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-slate-950 border-r border-slate-800">
|
||||
<div className="flex min-h-0 flex-1 flex-col border-r border-slate-800 bg-slate-950">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pb-4">
|
||||
<nav
|
||||
className="mt-2 flex flex-col h-full justify-between space-y-1 px-2"
|
||||
className="mt-2 flex h-full flex-col justify-between space-y-1 px-2"
|
||||
aria-label="Sidebar"
|
||||
>
|
||||
<div>
|
||||
<div className="group flex items-center my-2 text-slate-400 rounded hover:bg-slate-950 border border-transparent hover:border-slate-800 transition divide-x divide-transparent hover:divide-slate-900">
|
||||
<div className="group my-2 flex items-center divide-x divide-transparent rounded border border-transparent text-slate-400 transition hover:divide-slate-900 hover:border-slate-800 hover:bg-slate-950">
|
||||
<Link
|
||||
to={backPath}
|
||||
className="px-2 py-3 hover:bg-slate-800 rounded-l transition"
|
||||
className="rounded-l px-2 py-3 transition hover:bg-slate-800"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5 text-slate-400" />
|
||||
</Link>
|
||||
|
||||
<Header1
|
||||
size="small"
|
||||
className="pl-2 py-2 w-full text-slate-400 rounded-r hover:bg-slate-800 transition whitespace-nowrap text-ellipsis overflow-hidden"
|
||||
className="w-full overflow-hidden text-ellipsis whitespace-nowrap rounded-r py-2 pl-2 text-slate-400 transition hover:bg-slate-800"
|
||||
>
|
||||
<Link to="">{title}</Link>
|
||||
</Header1>
|
||||
@@ -167,17 +173,17 @@ function SideMenu({
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ul className="flex flex-col gap-6 ml-3 mr-2">
|
||||
<ul className="ml-3 mr-2 flex flex-col gap-6">
|
||||
{organization.environments.map((environment) => {
|
||||
return (
|
||||
<li
|
||||
key={environment.id}
|
||||
className="flex flex-col gap-1.5 justify-between w-full"
|
||||
className="flex w-full flex-col justify-between gap-1.5"
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<Body
|
||||
size="extra-small"
|
||||
className={`text-slate-300 overflow-hidden ${menuSmallTitleStyle}`}
|
||||
className={`overflow-hidden text-slate-300 ${menuSmallTitleStyle}`}
|
||||
>
|
||||
{environment.slug} api key
|
||||
</Body>
|
||||
@@ -186,11 +192,11 @@ function SideMenu({
|
||||
value={environment.apiKey}
|
||||
/> */}
|
||||
</div>
|
||||
<div className="relative overflow-hidden text-sm select-all text-slate-400 p-1 pl-2 rounded-sm border border-slate-800">
|
||||
<span className="block absolute pointer-events-none right-7 top-0 bg-gradient-to-r from-transparent to-slate-950 w-20 h-6"></span>
|
||||
<div className="relative select-all overflow-hidden rounded-sm border border-slate-800 p-1 pl-2 text-sm text-slate-400">
|
||||
<span className="pointer-events-none absolute right-7 top-0 block h-6 w-20 bg-gradient-to-r from-transparent to-slate-950"></span>
|
||||
<CopyText
|
||||
value={environment.apiKey}
|
||||
className="group absolute flex right-0 top-0 items-center justify-center rounded-sm w-7 h-full bg-slate-950 border-l border-slate-800 hover:bg-slate-900 transition hover:cursor-pointer active:bg-green-900"
|
||||
className="group absolute right-0 top-0 flex h-full w-7 items-center justify-center rounded-sm border-l border-slate-800 bg-slate-950 transition hover:cursor-pointer hover:bg-slate-900 active:bg-green-900"
|
||||
>
|
||||
<ClipboardDocumentCheckIcon className="h-5 w-5 group-active:text-green-500" />
|
||||
</CopyText>
|
||||
@@ -200,7 +206,7 @@ function SideMenu({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<ul className="flex flex-col gap-3 ml-3">
|
||||
<ul className="ml-3 flex flex-col gap-3">
|
||||
<li>
|
||||
<Body size="extra-small" className={menuSmallTitleStyle}>
|
||||
Help and resources
|
||||
|
||||
@@ -34,7 +34,7 @@ export function Classic({ children, ...props }: HeadlessTabProps) {
|
||||
export function UnderlinedList({ children, ...props }: HeadlessTabListProps) {
|
||||
return (
|
||||
<HeadlessTab.List
|
||||
className={"-mb-px flex space-x-4 border-b border-slate-200"}
|
||||
className={"-mb-px flex space-x-4 border-b border-slate-700"}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -48,10 +48,10 @@ export function Underlined({ children, ...props }: HeadlessTabProps) {
|
||||
className={({ selected }: { selected: boolean }) =>
|
||||
classnames(
|
||||
selected
|
||||
? "border-blue-500 text-slate-900 outline-none"
|
||||
: "border-transparent text-slate-800 hover:border-slate-200 hover:text-slate-700",
|
||||
? "border-slate-300 text-slate-300 outline-none"
|
||||
: "border-transparent text-slate-400 hover:border-slate-200 hover:text-slate-200",
|
||||
"disabled:text-slate-300 disabled:hover:border-transparent",
|
||||
"flex whitespace-nowrap border-b-2 py-2 px-4 text-xs font-medium"
|
||||
"flex whitespace-nowrap border-b-2 py-2 px-4 text-base font-medium transition"
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
@@ -69,7 +69,7 @@ export function SegmentedList({
|
||||
return (
|
||||
<HeadlessTab.List
|
||||
className={classNames(
|
||||
"flex ml-8 gap-0.5 max-w-fit bg-slate-200 rounded-md p-0.5 border border-slate-300",
|
||||
"flex max-w-fit gap-0.5 rounded-md bg-slate-800 p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -85,8 +85,8 @@ export function Segmented({ children, ...props }: HeadlessTabProps) {
|
||||
className={({ selected }: { selected: boolean }) =>
|
||||
classnames(
|
||||
selected
|
||||
? "bg-blue-500 text-white rounded shadow outline-none"
|
||||
: "text-slate-800 hover:bg-slate-300 rounded hover:text-slate-700 hover:shadow-none transition",
|
||||
? "rounded bg-indigo-600 text-white shadow outline-none"
|
||||
: "rounded text-slate-300 transition hover:bg-slate-700 hover:text-slate-300 hover:shadow-none",
|
||||
"flex whitespace-nowrap py-2 px-4 text-xs font-medium"
|
||||
)
|
||||
}
|
||||
@@ -96,3 +96,36 @@ export function Segmented({ children, ...props }: HeadlessTabProps) {
|
||||
</HeadlessTab>
|
||||
);
|
||||
}
|
||||
|
||||
export function LargeBoxList({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HeadlessTabListProps) {
|
||||
return (
|
||||
<HeadlessTab.List
|
||||
className={classNames("grid grid-cols-carousel gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</HeadlessTab.List>
|
||||
);
|
||||
}
|
||||
|
||||
export function LargeBox({ children, ...props }: HeadlessTabProps) {
|
||||
return (
|
||||
<HeadlessTab
|
||||
className={({ selected }: { selected: boolean }) =>
|
||||
classnames(
|
||||
selected
|
||||
? "bg-slate-800 text-white shadow outline-none"
|
||||
: "text-slate-300 transition hover:bg-slate-800/50 hover:text-slate-300 hover:shadow-none",
|
||||
"flex flex-col items-center justify-center gap-4 rounded border border-slate-700 py-6 px-4 text-base font-medium"
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</HeadlessTab>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Header2 } from "./Headers";
|
||||
|
||||
export function SubTitle({ children }: { children: React.ReactNode }) {
|
||||
export function SubTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Header2 size="small" className="mb-2 text-slate-400">
|
||||
<Header2 size="small" className={`mb-2 text-slate-400 ${className}`}>
|
||||
{children}
|
||||
</Header2>
|
||||
);
|
||||
|
||||
@@ -97,10 +97,10 @@ export function RunsTable({
|
||||
})
|
||||
)}
|
||||
{isLoading && (
|
||||
<tr className="absolute top-0 left-0 w-full h-full bg-slate-800/90">
|
||||
<tr className="absolute top-0 left-0 h-full w-full bg-slate-800/90">
|
||||
<td
|
||||
colSpan={6}
|
||||
className="flex items-center justify-center gap-2 text-white h-full"
|
||||
className="flex h-full items-center justify-center gap-2 text-white"
|
||||
>
|
||||
<Spinner /> Loading…
|
||||
</td>
|
||||
@@ -125,7 +125,7 @@ function Cell({
|
||||
alignment?: "left" | "right";
|
||||
}) {
|
||||
return (
|
||||
<td className="group-hover:bg-slate-850/50 transition cursor-pointer">
|
||||
<td className="cursor-pointer transition group-hover:bg-slate-850/50">
|
||||
<Link
|
||||
to={to}
|
||||
className={classNames(
|
||||
@@ -142,7 +142,7 @@ function Cell({
|
||||
function BlankRow({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-6 text-sm text-center">
|
||||
<td colSpan={6} className="py-6 text-center text-sm">
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -152,7 +152,7 @@ function BlankRow({ children }: { children: ReactNode }) {
|
||||
export function NoRuns({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<PanelWarning className="max-w-max">{title}</PanelWarning>
|
||||
<PanelWarning message={title} className="max-w-max" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export function customEvent(apiKey: string) {
|
||||
return `import { customEvent, Trigger } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
new Trigger({
|
||||
id: "user-created-notify-slack",
|
||||
name: "User Created - Notify Slack",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
on: customEvent({
|
||||
name: "user.created",
|
||||
schema: z.object({ id: z.string(), admin: z.boolean() }),
|
||||
filter: {
|
||||
admin: [false],
|
||||
},
|
||||
}),
|
||||
run: async (event, ctx) => {},
|
||||
}).listen();`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function githubIssues(apiKey: string) {
|
||||
return `import { Trigger } from "@trigger.dev/sdk";
|
||||
import * as github from "@trigger.dev/github";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
new Trigger({
|
||||
id: "github-issues-to-slack",
|
||||
name: "Posts to Slack when GitHub Issue created or modified",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
on: github.events.issueEvent({
|
||||
repo: "my-github-org/my-github-repo",
|
||||
}),
|
||||
|
||||
run: async (event, ctx) => {
|
||||
const response = await slack.postMessage("send-to-slack", {
|
||||
channelName: "my-slack-channel-name",
|
||||
text: \`A new issue has been created or modified. \${event.action}\`,
|
||||
});
|
||||
|
||||
return response.message;
|
||||
},
|
||||
}).listen();`;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function githubStars(apiKey: string) {
|
||||
return `import { Trigger } from "@trigger.dev/sdk";
|
||||
import * as github from "@trigger.dev/github";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
new Trigger({
|
||||
id: "new-github-star-to-slack",
|
||||
name: "New GitHub Star: triggerdotdev/trigger.dev",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
on: github.events.newStarEvent({
|
||||
repo: "triggerdotdev/trigger.dev",
|
||||
}),
|
||||
run: async (event) => {
|
||||
await slack.postMessage("github-stars", {
|
||||
channelName: "github-stars",
|
||||
text: \`New GitHub star from \n<\${event.sender.html_url}|\${event.sender.login}>\`,
|
||||
});
|
||||
},
|
||||
}).listen();`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export function newUserSlackMessage(apiKey: string) {
|
||||
return `import { Trigger, customEvent } from "@trigger.dev/sdk";
|
||||
import { postMessage } from "@trigger.dev/slack";
|
||||
import { z } from "zod";
|
||||
|
||||
new Trigger({
|
||||
id: "new-user",
|
||||
name: "New user slack message",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
on: customEvent({
|
||||
name: "user.created",
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
paidPlan: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
await ctx.logger.info("This log will appear on the Trigger.dev run page");
|
||||
|
||||
//send a message to the #new-users Slack channel with user details
|
||||
const response = await postMessage("send-to-slack", {
|
||||
channel: "new-users",
|
||||
text: \`New user: \${event.name} (\${event.email}) signed up. \${
|
||||
event.paidPlan ? "They are paying" : "They are on the free plan"
|
||||
}.\`,
|
||||
});
|
||||
|
||||
return response.message;
|
||||
},
|
||||
}).listen();`;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export function resendEmailDripCampaign(apiKey: string) {
|
||||
return `import { customEvent, Trigger, sendEvent } from "@trigger.dev/sdk";
|
||||
import * as resend from "@trigger.dev/resend";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
import { getUser } from "../db";
|
||||
import { InactiveEmail, TipsEmail, WelcomeEmail } from "./email-templates";
|
||||
|
||||
new Trigger({
|
||||
id: "welcome-email-campaign",
|
||||
name: "Welcome email drip campaign",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
on: customEvent({
|
||||
name: "user.created",
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
}),
|
||||
async run(event, context) {
|
||||
//get the user data from the database
|
||||
const user = await getUser(event.userId);
|
||||
|
||||
await slack.postMessage("send-to-slack", {
|
||||
channelName: "new-users",
|
||||
text: \`New user signed up: \${user.name} (\${user.email})\`,
|
||||
});
|
||||
|
||||
//Send the first email
|
||||
const welcomeResponse = await resend.sendEmail("welcome-email", {
|
||||
from: "Trigger.dev <james@email.trigger.dev>",
|
||||
replyTo: "James <james@trigger.dev>",
|
||||
to: user.email,
|
||||
subject: "Welcome to Trigger.dev",
|
||||
react: <WelcomeEmail name={user.name} />,
|
||||
});
|
||||
await context.logger.debug(
|
||||
\`Sent welcome email to \${welcomeResponse.to} with id \${welcomeResponse.id}
|
||||
\`
|
||||
);
|
||||
|
||||
//wait 1 day, check if the user has created a workflow and send the appropriate email
|
||||
await context.waitFor("wait-a-while", { days: 1 });
|
||||
const updatedUser = await getUser(event.userId);
|
||||
|
||||
if (updatedUser.hasOnboarded) {
|
||||
await resend.sendEmail("onboarding-complete", {
|
||||
from: "Trigger.dev <james@email.trigger.dev>",
|
||||
replyTo: "James <james@trigger.dev>",
|
||||
to: updatedUser.email,
|
||||
subject: "Pro tips for workflows",
|
||||
react: <TipsEmail name={updatedUser.name} />,
|
||||
});
|
||||
} else {
|
||||
await resend.sendEmail("onboarding-incomplete", {
|
||||
from: "Trigger.dev <james@email.trigger.dev>",
|
||||
replyTo: "James <james@trigger.dev>",
|
||||
to: updatedUser.email,
|
||||
subject: "Help with your first workflow",
|
||||
react: <InactiveEmail name={updatedUser.name} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
}).listen();`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
StarIcon,
|
||||
EnvelopeIcon,
|
||||
ShoppingCartIcon,
|
||||
ChatBubbleOvalLeftEllipsisIcon,
|
||||
UserIcon,
|
||||
DocumentTextIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { customEvent } from "./custom-event";
|
||||
import { githubIssues } from "./github-issues";
|
||||
import { githubStars } from "./github-stars";
|
||||
import { newUserSlackMessage } from "./new-user-slack-message";
|
||||
import { resendEmailDripCampaign } from "./resend-email-drip-campaign";
|
||||
import { shopifyCreateNewProducts } from "./shopify-create-new-product";
|
||||
import { webhook } from "./webhook";
|
||||
import { scheduled } from "./scheduled";
|
||||
|
||||
import { whatsappListenForMessageAndReply } from "./whatsapp-listen-for-message-and-reply";
|
||||
import { scheduledCron } from "./scheduled-cron";
|
||||
|
||||
export const exampleProjects = [
|
||||
{
|
||||
icon: <StarIcon className="h-8 w-8 text-yellow-400" />,
|
||||
name: "GitHub star → Slack",
|
||||
title: "When you receive a GitHub star, post that user's details to Slack",
|
||||
description:
|
||||
"This workflow is triggered when a GitHub user adds a star to a repository. The user's details will then be posted in a specific Slack channel.",
|
||||
requiredPackages: "@trigger.dev/github @trigger.dev/slack zod",
|
||||
code: githubStars,
|
||||
},
|
||||
|
||||
{
|
||||
icon: <EnvelopeIcon className="h-8 w-8 text-blue-400" />,
|
||||
name: "New user → email",
|
||||
title:
|
||||
"When a new user signs up, post a message to Slack and send them a series of emails",
|
||||
description:
|
||||
"This workflow is triggered when a new user signs up. A welcome email is sent straight away and an alert is sent to a specific Slack channel. 1 day later it checks if the user has completed the onboarding, if they have, they get a ‘tips’ email, otherwise they get a re-engagement email.",
|
||||
requiredPackages: "@trigger.dev/resend @trigger.dev/slack zod",
|
||||
code: resendEmailDripCampaign,
|
||||
},
|
||||
{
|
||||
icon: <UserIcon className="h-8 w-8 text-rose-400" />,
|
||||
name: "New user → Slack",
|
||||
title: "When a new user signs up, post a message to Slack",
|
||||
description:
|
||||
"This workflow is triggered when a new user signs up. The user's details will then be posted in a specific Slack channel.",
|
||||
requiredPackages: "@trigger.dev/slack zod",
|
||||
code: newUserSlackMessage,
|
||||
},
|
||||
|
||||
{
|
||||
icon: <ShoppingCartIcon className="h-8 w-8 text-purple-400" />,
|
||||
name: "New item → Shopify",
|
||||
title: "When a custom event is triggered, create a new product in Shopify",
|
||||
description:
|
||||
"This workflow is triggered by a custom event. Once it is triggered, a new product is created in Shopify with the specified details.",
|
||||
requiredPackages: "@trigger.dev/shopify zod",
|
||||
code: shopifyCreateNewProducts,
|
||||
packagesCopy: "Shopify",
|
||||
},
|
||||
{
|
||||
icon: <DocumentTextIcon className="h-8 w-8 text-orange-400" />,
|
||||
name: "GitHub issue → Slack",
|
||||
title: "When a GitHub issue is created or modified, post it to Slack",
|
||||
description:
|
||||
"This workflow is triggered when a new issue is created or modified in GitHub. The issue will then be posted in a specific Slack channel.",
|
||||
requiredPackages: "@trigger.dev/github @trigger.dev/slack zod",
|
||||
code: githubIssues,
|
||||
},
|
||||
|
||||
{
|
||||
icon: <ChatBubbleOvalLeftEllipsisIcon className="h-8 w-8 text-green-400" />,
|
||||
name: "WhatsApp → Slack",
|
||||
title: "Listen for WhatsApp messages and automatically reply",
|
||||
description:
|
||||
"This workflow is triggered when a WhatsApp message has been received. When received, a pre-determined reply is sent.",
|
||||
requiredPackages: "@trigger.dev/whatsapp zod",
|
||||
code: whatsappListenForMessageAndReply,
|
||||
packagesCopy: "WhatsApp",
|
||||
},
|
||||
];
|
||||
|
||||
export const fromScratchProjects = [
|
||||
{
|
||||
name: "Webhook",
|
||||
requiredPackages: "@trigger.dev/sdk zod",
|
||||
code: webhook,
|
||||
docsLink: "https://docs.trigger.dev/triggers/webhooks",
|
||||
description:
|
||||
"Webhooks allow you to subscribe to events from APIs but can be difficult to work with, especially when developing locally. Trigger.dev makes using webhooks easy:",
|
||||
bulletPoint1:
|
||||
"You don’t need to register/unregister for webhooks, we do it for you.",
|
||||
bulletPoint2:
|
||||
"They work locally during development without needing to use tunnels (e.g. Ngrok).",
|
||||
bulletPoint3:
|
||||
"We receive the webhook, then keep trying to send it to you until you receive it. If your server goes down, no problem.",
|
||||
},
|
||||
{
|
||||
name: "Custom event",
|
||||
requiredPackages: "@trigger.dev/sdk zod",
|
||||
code: customEvent,
|
||||
docsLink: "https://docs.trigger.dev/triggers/custom-events",
|
||||
description:
|
||||
"Custom event triggers allow you to run workflows from your own code (or your other workflows). Send an event and any workflows that subscribe to that custom event will get triggered. You can easily send an event from anywhere, including from inside another workflow. Events don’t have to come from the same server as your workflow and can be sent as HTTP requests from any language.",
|
||||
bulletPoint1: "",
|
||||
bulletPoint2: "",
|
||||
bulletPoint3: "",
|
||||
},
|
||||
{
|
||||
name: "Scheduled (recurring)",
|
||||
requiredPackages: "@trigger.dev/sdk zod",
|
||||
code: scheduled,
|
||||
docsLink: "https://docs.trigger.dev/triggers/scheduled",
|
||||
description:
|
||||
"Run a workflow on a recurring schedule. The example below will run every 5 minutes, starting 5 minutes after this code is first run on your server (that includes running locally).",
|
||||
bulletPoint1: "",
|
||||
bulletPoint2: "",
|
||||
bulletPoint3: "",
|
||||
},
|
||||
{
|
||||
name: "Scheduled (CRON)",
|
||||
requiredPackages: "@trigger.dev/sdk zod",
|
||||
code: scheduledCron,
|
||||
docsLink: "https://docs.trigger.dev/triggers/scheduled",
|
||||
description:
|
||||
"Run a workflow on a recurring schedule. The example job below will run at 2:30pm every Monday.",
|
||||
bulletPoint1: "",
|
||||
bulletPoint2: "",
|
||||
bulletPoint3: "",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,20 @@
|
||||
export function scheduledCron(apiKey: string) {
|
||||
return `import { Trigger, scheduleEvent } from "@trigger.dev/sdk";
|
||||
|
||||
new Trigger({
|
||||
id: "cron-scheduled-workflow",
|
||||
name: "Cron Scheduled Workflow",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
on: scheduleEvent({ cron: "30 14 * * 1" }),
|
||||
run: async (event, ctx) => {
|
||||
await ctx.logger.info("Received the cron scheduled event", {
|
||||
event,
|
||||
wallTime: new Date(),
|
||||
});
|
||||
|
||||
return { foo: "bar" };
|
||||
},
|
||||
}).listen();`;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function scheduled(apiKey: string) {
|
||||
return `import { Trigger, scheduleEvent } from "@trigger.dev/sdk";
|
||||
|
||||
new Trigger({
|
||||
id: "scheduled-workflow",
|
||||
name: "Scheduled Workflow",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
on: scheduleEvent({ rateOf: { minutes: 5 } }),
|
||||
run: async (event, ctx) => {
|
||||
await ctx.logger.info("Received the scheduled event", {
|
||||
event,
|
||||
wallTime: new Date(),
|
||||
});
|
||||
|
||||
return { foo: "bar" };
|
||||
},
|
||||
}).listen();`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export function shopifyCreateNewProducts(apiKey: string) {
|
||||
return `import { Trigger, customEvent } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
import * as shopify from "@trigger.dev/shopify";
|
||||
|
||||
new Trigger({
|
||||
id: "shopify-products",
|
||||
name: "Shopify products",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
on: customEvent({
|
||||
name: "shopify.products",
|
||||
schema: z.object({}),
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
const newProduct = await shopify.createProduct("create-product", {
|
||||
descriptionHtml: "This is my brilliant <i>product description</i>.",
|
||||
title: \`Fantastic product \${Math.floor(Math.random() * 1000)}\`,
|
||||
productType: "t-shirt",
|
||||
vendor: "Nike",
|
||||
options: ["Color", "Size"],
|
||||
standardizedProductType: {
|
||||
productTaxonomyNodeId: "gid://shopify/ProductTaxonomyNode/352",
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
price: "99.99",
|
||||
sku: "variant-1",
|
||||
inventoryItem: {
|
||||
tracked: true,
|
||||
},
|
||||
inventoryQuantities: [
|
||||
{
|
||||
availableQuantity: 1,
|
||||
locationId: "gid://shopify/Location/76187369773",
|
||||
},
|
||||
],
|
||||
options: ["Maroon", "Tiny"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const newImages = await shopify.appendProductImages("append-images", {
|
||||
id: newProduct.id,
|
||||
images: [
|
||||
{
|
||||
src: "https://via.placeholder.com/600/92c952.png",
|
||||
altText: "Image 1",
|
||||
},
|
||||
{
|
||||
src: "https://via.placeholder.com/600/d32776.png",
|
||||
altText: "Image 2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return newProduct;
|
||||
},
|
||||
}).listen();
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function webhook(apiKey: string) {
|
||||
return `import { webhookEvent, Trigger } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
new Trigger({
|
||||
id: "caldotcom-to-slack",
|
||||
name: "Cal.com To Slack",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
on: webhookEvent({
|
||||
service: "cal.com",
|
||||
eventName: "BOOKING_CREATED",
|
||||
filter: {
|
||||
triggerEvent: ["BOOKING_CREATED"],
|
||||
},
|
||||
schema: z.any(),
|
||||
verifyPayload: {
|
||||
enabled: true,
|
||||
header: "X-Cal-Signature-256",
|
||||
},
|
||||
}),
|
||||
run: async (event, ctx) => {},
|
||||
}).listen();`;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export function whatsappListenForMessageAndReply(apiKey: string) {
|
||||
return `import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendReaction, sendText } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
id: "whatsapp-webhook",
|
||||
name: "Listen for WhatsApp messages and reply",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
//this listens for all WhatsApp messages sent to your WhatsApp Business account
|
||||
on: events.messageEvent({
|
||||
accountId: "<your_account_id>",
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//these logs will appear on the run page
|
||||
await ctx.logger.info(\`Message data\`, event.message);
|
||||
await ctx.logger.info(\`Phone number\`, event.contacts[0]);
|
||||
|
||||
//add a 🥰 reaction to the original message
|
||||
const reactionResponse = await sendReaction("reaction", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
isReplyTo: event.message.id,
|
||||
emoji: "🥰",
|
||||
});
|
||||
|
||||
//send a text message in response
|
||||
const textResponse = await sendText("text-msg", {
|
||||
fromId: event.metadata.phone_number_id,
|
||||
to: event.message.from,
|
||||
text: "Hello! This is a text sent automatically from https://www.trigger.dev",
|
||||
});
|
||||
|
||||
//we support all other types of WhatsApp messages (audio, video, document, location, etc)
|
||||
},
|
||||
}).listen();`;
|
||||
}
|
||||
|
||||
@@ -46,22 +46,36 @@ function SlackInteraction({
|
||||
{trigger.name}
|
||||
</Header2>
|
||||
</div>
|
||||
<div className="flex gap-2 items-baseline">
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Block
|
||||
</Body>
|
||||
<Header2 size="small" className="text-slate-300 mb-2">
|
||||
{trigger.source.blockId}
|
||||
</Header2>
|
||||
</div>
|
||||
<div className="flex gap-2 items-baseline">
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Action
|
||||
</Body>
|
||||
<Header2 size="small" className="text-slate-300 mb-2">
|
||||
{trigger.source.actionIds.join(", ")}
|
||||
</Header2>
|
||||
</div>
|
||||
{trigger.source.type === "block_action" && (
|
||||
<div className="flex gap-2 items-baseline">
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Block
|
||||
</Body>
|
||||
<Header2 size="small" className="text-slate-300 mb-2">
|
||||
{trigger.source.blockId}
|
||||
</Header2>
|
||||
</div>
|
||||
)}
|
||||
{trigger.source.type === "block_action" && (
|
||||
<div className="flex gap-2 items-baseline">
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Action
|
||||
</Body>
|
||||
<Header2 size="small" className="text-slate-300 mb-2">
|
||||
{trigger.source.actionIds.join(", ")}
|
||||
</Header2>
|
||||
</div>
|
||||
)}
|
||||
{trigger.source.type === "view_submission" && (
|
||||
<div className="flex gap-2 items-baseline">
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Callback IDs
|
||||
</Body>
|
||||
<Header2 size="small" className="text-slate-300 mb-2">
|
||||
{trigger.source.callbackIds.join(", ")}
|
||||
</Header2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,8 @@ import { RemixBrowser, useLocation, useMatches } from "@remix-run/react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import * as Sentry from "@sentry/remix";
|
||||
import { useEffect } from "react";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
|
||||
const hydrate = () =>
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
||||
|
||||
if (window.requestIdleCallback) {
|
||||
window.requestIdleCallback(hydrate);
|
||||
} else {
|
||||
// Safari doesn't support requestIdleCallback
|
||||
// https://caniuse.com/requestidlecallback
|
||||
window.setTimeout(hydrate, 1);
|
||||
}
|
||||
hydrateRoot(document, <RemixBrowser />);
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
Sentry.init({
|
||||
|
||||
@@ -1,114 +1,29 @@
|
||||
import * as Sentry from "~/services/sentry.server";
|
||||
import * as MessageBroker from "~/services/messageBroker.server";
|
||||
import { PassThrough } from "stream";
|
||||
import type { EntryContext } from "@remix-run/node";
|
||||
import { Response } from "@remix-run/node";
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import isbot from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import * as MessageBroker from "~/services/messageBroker.server";
|
||||
import * as Sentry from "~/services/sentry.server";
|
||||
|
||||
const ABORT_DELAY = 5000;
|
||||
|
||||
const handleRequest = (
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) =>
|
||||
isbot(request.headers.get("user-agent"))
|
||||
? handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
)
|
||||
: handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
);
|
||||
export default handleRequest;
|
||||
) {
|
||||
// deepcode ignore Ssti: <This is recommended by Remix>
|
||||
const markup = renderToString(
|
||||
// deepcode ignore OR: <All good in the hood>
|
||||
<RemixServer context={remixContext} url={request.url} />
|
||||
);
|
||||
|
||||
const handleBotRequest = (
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let didError = false;
|
||||
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer context={remixContext} url={request.url} />,
|
||||
{
|
||||
onAllReady: () => {
|
||||
const body = new PassThrough();
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(body, {
|
||||
headers: responseHeaders,
|
||||
status: didError ? 500 : responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError: (error: unknown) => {
|
||||
reject(error);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
didError = true;
|
||||
|
||||
console.error(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
|
||||
const handleBrowserRequest = (
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let didError = false;
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer context={remixContext} url={request.url} />,
|
||||
{
|
||||
onShellReady: () => {
|
||||
const body = new PassThrough();
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(body, {
|
||||
headers: responseHeaders,
|
||||
status: didError ? 500 : responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError: (error: unknown) => {
|
||||
reject(error);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
didError = true;
|
||||
|
||||
console.error(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
return new Response("<!DOCTYPE html>" + markup, {
|
||||
status: responseStatusCode,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
Sentry.init();
|
||||
MessageBroker.init();
|
||||
|
||||
@@ -36,9 +36,7 @@ export function useEnvironments(): RuntimeEnvironment[] | undefined {
|
||||
|
||||
export function useCurrentEnvironment(): RuntimeEnvironment | undefined {
|
||||
const environments = useEnvironments();
|
||||
const routeMatch = useMatchesData(
|
||||
"routes/__app/orgs/$organizationSlug/workflows/$workflowSlug"
|
||||
);
|
||||
const routeMatch = useMatchesData("routes/__app/orgs/$organizationSlug");
|
||||
|
||||
const currentEnvironmentSlug = routeMatch?.data.currentEnvironmentSlug;
|
||||
if (!currentEnvironmentSlug || !environments) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIConnection, Organization } from ".prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { Workflow } from "./workflow.server";
|
||||
|
||||
export { APIConnection };
|
||||
|
||||
@@ -117,3 +118,43 @@ export async function updateApiConnectionTitle({
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApiConnectionsForWorkflow({
|
||||
workflowId,
|
||||
}: {
|
||||
workflowId: Workflow["id"];
|
||||
}) {
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: {
|
||||
id: workflowId,
|
||||
},
|
||||
select: {
|
||||
externalSource: {
|
||||
select: {
|
||||
service: true,
|
||||
connection: true,
|
||||
},
|
||||
},
|
||||
externalServices: {
|
||||
select: {
|
||||
service: true,
|
||||
connection: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) return [];
|
||||
|
||||
let connections: APIConnection[] = [];
|
||||
if (workflow.externalSource?.connection) {
|
||||
connections.push(workflow.externalSource.connection);
|
||||
}
|
||||
workflow.externalServices.forEach((s) => {
|
||||
if (s.connection) {
|
||||
connections.push(s.connection);
|
||||
}
|
||||
});
|
||||
|
||||
return connections;
|
||||
}
|
||||
|
||||
@@ -243,17 +243,36 @@ function triggerProperties(
|
||||
};
|
||||
}
|
||||
|
||||
const slackSource = SlackInteractionSourceSchema.parse(
|
||||
const slackSource = SlackInteractionSourceSchema.safeParse(
|
||||
internalSource.source
|
||||
);
|
||||
|
||||
if (!slackSource.success) {
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Slack interaction",
|
||||
title: "on: Slack interaction",
|
||||
};
|
||||
}
|
||||
|
||||
const title =
|
||||
slackSource.data.type === "block_action"
|
||||
? `block_id = ${slackSource.data.blockId}`
|
||||
: `callback_id = ${slackSource.data.callbackIds.join(", ")}`;
|
||||
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Slack interaction",
|
||||
title: `block_id = ${slackSource.blockId}`,
|
||||
title: title,
|
||||
properties:
|
||||
slackSource.actionIds.length > 0
|
||||
? [{ key: "Action ID", value: slackSource.actionIds.join(", ") }]
|
||||
slackSource.data.type === "block_action" &&
|
||||
slackSource.data.actionIds.length > 0
|
||||
? [
|
||||
{
|
||||
key: "Action ID",
|
||||
value: slackSource.data.actionIds.join(", "),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +89,13 @@ async function parseStep(
|
||||
status: original.status,
|
||||
};
|
||||
switch (original.type) {
|
||||
case "RUN_ONCE":
|
||||
return {
|
||||
...base,
|
||||
type: "RUN_ONCE" as const,
|
||||
output: original.output,
|
||||
idempotencyKey: original.id,
|
||||
};
|
||||
case "LOG_MESSAGE":
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -64,3 +64,23 @@ export async function createStepOnce(
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findWorkflowStepById(stepId: string) {
|
||||
return prisma.workflowRunStep.findUnique({
|
||||
where: {
|
||||
id: stepId,
|
||||
},
|
||||
include: {
|
||||
run: {
|
||||
include: {
|
||||
workflow: true,
|
||||
environment: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { JSONSchemaFaker } from "json-schema-faker";
|
||||
import type { Workflow, WorkflowRun } from ".prisma/client";
|
||||
|
||||
export class WorkflowTestPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async data({
|
||||
organizationSlug,
|
||||
workflowSlug,
|
||||
environmentSlug,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
workflowSlug: string;
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
const workflow = await this.#prismaClient.workflow.findFirst({
|
||||
where: {
|
||||
slug: workflowSlug,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
runs: {
|
||||
where: {
|
||||
environment: {
|
||||
slug: environmentSlug,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
event: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
externalSource: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error("Workflow not found");
|
||||
}
|
||||
|
||||
const payload = await this.#getPayload(workflow, workflow.runs[0]);
|
||||
|
||||
const status =
|
||||
workflow.status === "CREATED"
|
||||
? workflow.type === "WEBHOOK" &&
|
||||
workflow.externalSource?.manualRegistration
|
||||
? "TESTABLE"
|
||||
: "CREATED"
|
||||
: workflow.status;
|
||||
|
||||
return { payload, status };
|
||||
}
|
||||
|
||||
async #getPayload(
|
||||
workflow: Workflow,
|
||||
lastRun?: WorkflowRun & { event: { payload: any } }
|
||||
) {
|
||||
if (workflow.type === "SCHEDULE") {
|
||||
return {
|
||||
scheduledTime: new Date(),
|
||||
lastRunAt: lastRun?.startedAt ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastRun) {
|
||||
return lastRun.event.payload;
|
||||
}
|
||||
|
||||
if (workflow.jsonSchema) {
|
||||
// @ts-ignore
|
||||
return JSONSchemaFaker.generate(workflow.jsonSchema);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import { getOrganizations } from "~/models/organization.server";
|
||||
import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { Header } from "~/components/layout/Header";
|
||||
import { NoMobileOverlay } from "~/components/NoMobileOverlay";
|
||||
import { IntercomProvider, useIntercom } from "react-use-intercom";
|
||||
import { useEffect } from "react";
|
||||
|
||||
|
||||
export const loader = async ({ request }: LoaderArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -24,14 +28,32 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
);
|
||||
};
|
||||
|
||||
const INTERCOM_APP_ID = "pfbctmiv";
|
||||
|
||||
export default function App() {
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Header />
|
||||
<AppBody>
|
||||
<Outlet />
|
||||
</AppBody>
|
||||
<Footer />
|
||||
</AppLayout>
|
||||
<IntercomProvider appId={INTERCOM_APP_ID}>
|
||||
<AppLayout>
|
||||
<NoMobileOverlay />
|
||||
<Header />
|
||||
<AppBody>
|
||||
<IntercomSurvey />
|
||||
<Outlet />
|
||||
</AppBody>
|
||||
<Footer />
|
||||
</AppLayout>
|
||||
</IntercomProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function IntercomSurvey() {
|
||||
const { boot, shutdown, hide, show, update } = useIntercom();
|
||||
|
||||
useEffect(() => {
|
||||
boot({});
|
||||
return () => shutdown();
|
||||
}, []);
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function AppLayout() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-center m-20">
|
||||
<ul className="grid grid-cols-4 max-w-8xl gap-2">
|
||||
<ul className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 max-w-8xl gap-2">
|
||||
{organizations ? (
|
||||
<OrganizationGrid organizations={organizations} />
|
||||
) : (
|
||||
@@ -23,7 +23,7 @@ export default function AppLayout() {
|
||||
<Link
|
||||
to="orgs/new"
|
||||
className={classNames(
|
||||
"border-2 border-slate-800 hover:border-transparent hover:bg-slate-800/50 hover:shadow-md",
|
||||
"border-2 border-slate-800 text-center hover:border-transparent hover:bg-slate-800/50 hover:shadow-md",
|
||||
boxClasses
|
||||
)}
|
||||
>
|
||||
@@ -67,7 +67,7 @@ function OrganizationGridItem({
|
||||
<Link
|
||||
to={`orgs/${organization.slug}`}
|
||||
className={classNames(
|
||||
"bg-slate-800 shadow-md hover:bg-slate-800/50",
|
||||
"bg-slate-800 shadow-md text-center hover:bg-slate-800/50",
|
||||
boxClasses
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requireUserId } from "~/services/session.server";
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { getOrganizationFromSlug } from "~/models/organization.server";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -19,7 +20,11 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
return typedjson({ organization });
|
||||
const currentEnvironmentSlug = await getRuntimeEnvironmentFromRequest(
|
||||
request
|
||||
);
|
||||
|
||||
return typedjson({ organization, currentEnvironmentSlug });
|
||||
};
|
||||
|
||||
export default function Organization() {
|
||||
|
||||
@@ -8,12 +8,11 @@ import classNames from "classnames";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { ApiLogoIcon } from "~/components/code/ApiLogoIcon";
|
||||
import {
|
||||
CreateNewWorkflow,
|
||||
CreateNewWorkflowNoWorkflows,
|
||||
} from "~/components/CreateNewWorkflow";
|
||||
import { CreateNewWorkflow } from "~/components/CreateNewWorkflow";
|
||||
import { Container } from "~/components/layout/Container";
|
||||
import { List } from "~/components/layout/List";
|
||||
import { PanelInfo } from "~/components/layout/PanelInfo";
|
||||
import { PrimaryLink } from "~/components/primitives/Buttons";
|
||||
import { Body } from "~/components/primitives/text/Body";
|
||||
import { Header2, Header3 } from "~/components/primitives/text/Headers";
|
||||
import { SubTitle } from "~/components/primitives/text/SubTitle";
|
||||
@@ -58,8 +57,16 @@ export default function Page() {
|
||||
<Title>Workflows</Title>
|
||||
{workflows.length === 0 ? (
|
||||
<>
|
||||
<SubTitle>Create your first workflow</SubTitle>
|
||||
<CreateNewWorkflowNoWorkflows providers={providers} />
|
||||
<SubTitle>0 workflows</SubTitle>
|
||||
<PanelInfo
|
||||
message="You don't have any workflows yet. They will appear here once
|
||||
connected."
|
||||
className="mb-4 max-w-4xl p-4 pr-6"
|
||||
>
|
||||
<PrimaryLink to={`/orgs/${currentOrganization.slug}/workflows/new`}>
|
||||
Create first workflow
|
||||
</PrimaryLink>
|
||||
</PanelInfo>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -92,30 +99,36 @@ function WorkflowList({
|
||||
<Link
|
||||
to={`/orgs/${currentOrganizationSlug}/workflows/${workflow.slug}`}
|
||||
className={classNames(
|
||||
"block hover:bg-slate-850/40 transition",
|
||||
"relative block overflow-hidden transition hover:bg-slate-850/40",
|
||||
workflow.status === "DISABLED" ? workflowDisabled : ""
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between lg:items-center flex-col lg:flex-row flex-wrap lg:flex-nowrap pl-4 pr-4 py-4">
|
||||
<div className="flex items-center flex-1 justify-between">
|
||||
{workflow.lastRun === undefined && (
|
||||
<div className="absolute top-2 -right-8 rotate-45 bg-green-700 px-8 py-0.5 text-xs font-semibold uppercase tracking-wide text-green-200 shadow-md">
|
||||
New
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-wrap justify-between py-4 pl-4 pr-4 lg:flex-row lg:flex-nowrap lg:items-center">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="relative flex items-center">
|
||||
{workflow.status === "CREATED" && (
|
||||
<ExclamationTriangleIcon className="absolute -top-1.5 -left-1.5 h-6 w-6 text-amber-400" />
|
||||
)}
|
||||
<div className="p-3 bg-slate-850 rounded-md flex-shrink-0 self-start h-20 w-20 mr-4">
|
||||
<div className="mr-4 h-20 w-20 flex-shrink-0 self-start rounded-md bg-slate-850 p-3">
|
||||
<TriggerTypeIcon
|
||||
type={workflow.trigger.type}
|
||||
provider={workflow.integrations.source}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 mr-1 truncate">
|
||||
<div className="mr-1 flex flex-col gap-1 truncate">
|
||||
<Header2
|
||||
size="regular"
|
||||
className="truncate text-slate-200"
|
||||
>
|
||||
{workflow.title}
|
||||
</Header2>
|
||||
<div className="flex gap-2 items-baseline">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<PillLabel label={workflow.trigger.typeTitle} />
|
||||
<Header3
|
||||
size="extra-small"
|
||||
@@ -124,7 +137,7 @@ function WorkflowList({
|
||||
{workflow.trigger.title}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 items-baseline">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3">
|
||||
{workflow.trigger.properties &&
|
||||
workflow.trigger.properties.map((property) => (
|
||||
<WorkflowProperty
|
||||
@@ -137,12 +150,12 @@ function WorkflowList({
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="shrink-0 h-5 w-5 ml-5 text-slate-400 lg:hidden"
|
||||
className="ml-5 h-5 w-5 shrink-0 text-slate-400 lg:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center flex-grow lg:flex-grow-0">
|
||||
<div className="flex flex-wrap-reverse justify-between w-full lg:justify-end gap-3 items-center mt-2 lg:mt-0">
|
||||
<div className="flex flex-grow items-center lg:flex-grow-0">
|
||||
<div className="mt-2 flex w-full flex-wrap-reverse items-center justify-between gap-3 lg:mt-0 lg:justify-end">
|
||||
<div className="flex flex-col text-left lg:text-right">
|
||||
<Body size="extra-small" className="text-slate-500">
|
||||
Last run: {lastRunDescription(workflow.lastRun)}
|
||||
@@ -151,7 +164,7 @@ function WorkflowList({
|
||||
{workflow.slug}
|
||||
</Body>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{workflow.integrations.source && (
|
||||
<ApiLogoIcon
|
||||
integration={workflow.integrations.source}
|
||||
@@ -173,7 +186,7 @@ function WorkflowList({
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="shrink-0 h-5 w-5 ml-5 text-slate-400 hidden lg:block"
|
||||
className="ml-5 hidden h-5 w-5 shrink-0 text-slate-400 lg:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -204,7 +217,7 @@ function lastRunDescription(lastRun: WorkflowListItem["lastRun"]) {
|
||||
|
||||
function PillLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="px-1.5 py-1 text-[10px] font-semibold tracking-wide uppercase rounded text-slate-400 bg-slate-700">
|
||||
<span className="rounded bg-slate-700 px-1.5 py-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
@@ -222,7 +235,7 @@ function WorkflowProperty({
|
||||
<Body size="extra-small" className="uppercase text-slate-500">
|
||||
{label}
|
||||
</Body>
|
||||
<Body size="small" className="text-slate-400 truncate">
|
||||
<Body size="small" className="truncate text-slate-400">
|
||||
{content}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function Integrations() {
|
||||
{connections.map((connection) => {
|
||||
return (
|
||||
<li key={connection.id}>
|
||||
<div className="flex gap-4 items-center px-4 py-4">
|
||||
<div className="flex items-center gap-4 px-4 py-4">
|
||||
<ApiLogoIcon
|
||||
integration={findIntegrationMetadata(
|
||||
integrations,
|
||||
@@ -89,13 +89,13 @@ export default function Integrations() {
|
||||
|
||||
<div>
|
||||
<SubTitle>Add an API integration</SubTitle>
|
||||
<div className="flex flex-wrap gap-2 w-full">
|
||||
<div className="flex w-full flex-wrap gap-2">
|
||||
{integrations.map((integration) => (
|
||||
<ConnectButton
|
||||
key={integration.slug}
|
||||
integration={integration}
|
||||
organizationId={organization.id}
|
||||
className="flex flex-col group max-w-[160px] rounded-md bg-slate-800 border border-slate-800 gap-4 text-sm text-slate-200 items-center overflow-hidden hover:bg-slate-800/30 transition shadow-md disabled:opacity-50"
|
||||
className="group flex max-w-[160px] flex-col items-center gap-4 overflow-hidden rounded-md border border-slate-800 bg-slate-800 text-sm text-slate-200 shadow-md transition hover:bg-slate-800/30 disabled:opacity-50"
|
||||
>
|
||||
{(status) => (
|
||||
<AddButtonContent integration={integration} status={status} />
|
||||
@@ -104,14 +104,14 @@ export default function Integrations() {
|
||||
))}
|
||||
<a
|
||||
href="mailto:hello@trigger.dev"
|
||||
className="flex flex-col group max-w-[160px] rounded-md bg-slate-800 border border-slate-800 gap-4 text-sm text-slate-200 items-center overflow-hidden hover:bg-slate-800/30 transition shadow-md disabled:opacity-50"
|
||||
className="group flex max-w-[160px] flex-col items-center gap-4 overflow-hidden rounded-md border border-slate-800 bg-slate-800 text-sm text-slate-200 shadow-md transition hover:bg-slate-800/30 disabled:opacity-50"
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-full py-6 bg-black/20 border-b border-slate-800">
|
||||
<div className="relative flex w-full items-center justify-center border-b border-slate-800 bg-black/20 py-6">
|
||||
<EnvelopeIcon className="h-20 w-20 text-slate-400" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center text-center leading-relaxed text-slate-400">
|
||||
<span className="px-2.5">Need an integration?</span>
|
||||
<span className="px-6 text-slate-200 text-base">
|
||||
<span className="px-6 text-base text-slate-200">
|
||||
Let us know!
|
||||
</span>
|
||||
</div>
|
||||
@@ -131,25 +131,25 @@ function AddButtonContent({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex items-center justify-center w-full py-6 bg-black/20 border-b border-slate-800">
|
||||
<PlusCircleIcon className="absolute h-7 w-7 top-[6px] right-[6px] z-10 text-green-600 shadow-md" />
|
||||
<div className="relative flex w-full items-center justify-center border-b border-slate-800 bg-black/20 py-6 px-10">
|
||||
<PlusCircleIcon className="absolute top-[6px] right-[6px] z-10 h-7 w-7 text-green-600 shadow-md" />
|
||||
<img
|
||||
src={integration.icon}
|
||||
alt={integration.name}
|
||||
className="h-20 group-hover:opacity-80 transition"
|
||||
className="h-20 transition group-hover:opacity-80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === "loading" ? (
|
||||
<span className="px-6 pb-4 leading-relaxed text-green-500 animate-pulse">
|
||||
Connecting to{" "}
|
||||
<span className="text-slate-200 text-base">{integration.name}</span>
|
||||
</span>
|
||||
<div className="flex animate-pulse flex-col px-3 pb-4 leading-relaxed text-green-500">
|
||||
<span>Connecting to</span>
|
||||
<span className="text-base text-slate-200">{integration.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="px-6 pb-4 leading-relaxed text-slate-400">
|
||||
Connect to{" "}
|
||||
<span className="text-slate-200 text-base">{integration.name}</span>
|
||||
</span>
|
||||
<div className="flex flex-col px-3 pb-4">
|
||||
<span className="text-slate-400">Connect to</span>
|
||||
<span className="text-base text-slate-200">{integration.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,388 @@
|
||||
import type { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { CreateNewWorkflowNoWorkflows } from "~/components/CreateNewWorkflow";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { ActionArgs, LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { redirect } from "@remix-run/server-runtime";
|
||||
import classNames from "classnames";
|
||||
import { typedjson, useTypedFetcher } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import CodeBlock from "~/components/code/CodeBlock";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { InstallPackages } from "~/components/CreateNewWorkflow";
|
||||
import { Container } from "~/components/layout/Container";
|
||||
import { SubTitle } from "~/components/primitives/text/SubTitle";
|
||||
import { Panel } from "~/components/layout/Panel";
|
||||
import {
|
||||
PrimaryButton,
|
||||
PrimaryLink,
|
||||
TertiaryA,
|
||||
} from "~/components/primitives/Buttons";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
LargeBox,
|
||||
LargeBoxList,
|
||||
Underlined,
|
||||
UnderlinedList,
|
||||
} from "~/components/primitives/Tabs";
|
||||
import { Body } from "~/components/primitives/text/Body";
|
||||
import { Header4 } from "~/components/primitives/text/Headers";
|
||||
import { Title } from "~/components/primitives/text/Title";
|
||||
import {
|
||||
exampleProjects,
|
||||
fromScratchProjects,
|
||||
} from "~/components/samples/samplesList";
|
||||
import { useCurrentEnvironment } from "~/hooks/useEnvironments";
|
||||
import { useCurrentOrganization } from "~/hooks/useOrganizations";
|
||||
import { getIntegrationMetadatas } from "~/models/integrations.server";
|
||||
import { getOrganizationFromSlug } from "~/models/organization.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
const urlSearchParamsSchema = z.object({
|
||||
date: z.coerce.number().transform((value) => new Date(value)),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
await requireUserId(request);
|
||||
|
||||
//add the date to the url so we can tell if a workflow is new
|
||||
const url = new URL(request.url);
|
||||
const searchObject = Object.fromEntries(url.searchParams ?? {});
|
||||
const result = urlSearchParamsSchema.safeParse(searchObject);
|
||||
if (!result.success) {
|
||||
url.searchParams.set("date", new Date().getTime().toString());
|
||||
throw redirect(url.toString());
|
||||
}
|
||||
|
||||
const providers = getIntegrationMetadatas(false);
|
||||
|
||||
return typedjson({ providers });
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = params;
|
||||
invariant(organizationSlug, "organizationSlug is required");
|
||||
|
||||
const url = new URL(request.url);
|
||||
const result = urlSearchParamsSchema.safeParse(
|
||||
Object.fromEntries(url.searchParams)
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error("workflows.new action: Invalid date");
|
||||
return typedjson({ hasNewWorkflows: false, newWorkflow: undefined });
|
||||
}
|
||||
|
||||
const organization = await getOrganizationFromSlug({
|
||||
slug: organizationSlug,
|
||||
userId,
|
||||
});
|
||||
|
||||
const newWorkflow = organization?.workflows.find((workflow) => {
|
||||
return workflow.createdAt > result.data.date;
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
hasNewWorkflows: newWorkflow ? true : false,
|
||||
newWorkflow,
|
||||
});
|
||||
};
|
||||
|
||||
const maxWidth = "flex max-w-4xl";
|
||||
const subTitle = "text-slate-200 font-semibold mb-3";
|
||||
const carousel =
|
||||
"-ml-[26px] overflow-hidden overflow-x-auto pl-[1.5rem] scrollbar-hide";
|
||||
|
||||
export default function NewWorkflowPage() {
|
||||
const { providers } = useTypedLoaderData<typeof loader>();
|
||||
const environment = useCurrentEnvironment();
|
||||
const currentOrganization = useCurrentOrganization();
|
||||
invariant(currentOrganization, "Organization must be defined");
|
||||
invariant(environment, "Environment must be defined");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Title>New Workflow</Title>
|
||||
<SubTitle>Create a new workflow</SubTitle>
|
||||
<CreateNewWorkflowNoWorkflows providers={providers} />
|
||||
<Title>Create a new workflow</Title>
|
||||
<div className={classNames(maxWidth)}>
|
||||
<StepNumber stepNumber="1" drawLine />
|
||||
<div className="mb-6 w-full">
|
||||
<Header4 size="regular" className={subTitle}>
|
||||
Install the Trigger.dev package
|
||||
</Header4>
|
||||
<InstallPackages packages={"@trigger.dev/sdk"} />
|
||||
</div>
|
||||
</div>
|
||||
<Tab.Group>
|
||||
<div className={classNames(maxWidth)}>
|
||||
<StepNumber stepNumber="2" drawLine />
|
||||
<div className="mb-6 w-full pr-10">
|
||||
<Header4 size="regular" className={classNames(subTitle)}>
|
||||
Create your workflow
|
||||
</Header4>
|
||||
<UnderlinedList>
|
||||
<Underlined>Start from an example</Underlined>
|
||||
<Underlined>Start from scratch</Underlined>
|
||||
</UnderlinedList>
|
||||
<Tab.Panels className="flex-grow pt-4">
|
||||
<Tab.Panel className="relative h-full">
|
||||
{/* Example projects tabs */}
|
||||
<Tab.Group>
|
||||
<div
|
||||
className={classNames(
|
||||
carousel,
|
||||
"border-r border-slate-700"
|
||||
)}
|
||||
>
|
||||
<LargeBoxList>
|
||||
{exampleProjects.map((project) => {
|
||||
return (
|
||||
<LargeBox key={project.name}>
|
||||
{project.icon}
|
||||
<Body>{project.name}</Body>
|
||||
</LargeBox>
|
||||
);
|
||||
})}
|
||||
</LargeBoxList>
|
||||
</div>
|
||||
{/* Example projects content */}
|
||||
<Tab.Panels className={classNames("flex-grow pt-4")}>
|
||||
{exampleProjects.map((project) => {
|
||||
return (
|
||||
<Tab.Panel
|
||||
key={project.name}
|
||||
className="relative h-full"
|
||||
>
|
||||
<div className="">
|
||||
<div className="mb-4 mt-4 flex items-center gap-2">
|
||||
{project.icon}
|
||||
<Header4
|
||||
size="small"
|
||||
className="font-semibold text-slate-300"
|
||||
>
|
||||
{project.title}
|
||||
</Header4>
|
||||
</div>
|
||||
<Body
|
||||
size="regular"
|
||||
className="mb-4 text-slate-400"
|
||||
>
|
||||
{project.description}
|
||||
</Body>
|
||||
<Body
|
||||
size="regular"
|
||||
className="mb-2 text-slate-400"
|
||||
>
|
||||
Install these additional API integration packages:
|
||||
</Body>
|
||||
<InstallPackages
|
||||
packages={project.requiredPackages}
|
||||
/>
|
||||
<Body
|
||||
size="regular"
|
||||
className="mb-2 mt-4 text-slate-400"
|
||||
>
|
||||
Copy this example code into your project. Your API
|
||||
key has already been inserted.
|
||||
</Body>
|
||||
<CodeBlock
|
||||
code={project.code(environment.apiKey)}
|
||||
align="top"
|
||||
/>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="relative h-full">
|
||||
<Tab.Group>
|
||||
{/* From scratch projects titles */}
|
||||
<div className={classNames(carousel)}>
|
||||
<LargeBoxList>
|
||||
{fromScratchProjects.map((project) => {
|
||||
return (
|
||||
<LargeBox key={project.name}>{project.name}</LargeBox>
|
||||
);
|
||||
})}
|
||||
</LargeBoxList>
|
||||
</div>
|
||||
{/* From scratch projects content */}
|
||||
<Tab.Panels className={classNames("flex-grow pt-4")}>
|
||||
{fromScratchProjects.map((project) => {
|
||||
return (
|
||||
<Tab.Panel
|
||||
key={project.name}
|
||||
className="relative h-full"
|
||||
>
|
||||
<div className="">
|
||||
<Body
|
||||
size="regular"
|
||||
className="mb-4 text-slate-400"
|
||||
>
|
||||
{project.description}
|
||||
</Body>
|
||||
<ul className="ml-[17px] list-disc text-slate-400 marker:text-indigo-400">
|
||||
{project.bulletPoint1 ? (
|
||||
<li>{project.bulletPoint1}</li>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{project.bulletPoint2 ? (
|
||||
<li>{project.bulletPoint2}</li>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{project.bulletPoint3 ? (
|
||||
<li>{project.bulletPoint3}</li>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</ul>
|
||||
<Body
|
||||
size="regular"
|
||||
className="mb-2 mt-4 text-slate-400"
|
||||
>
|
||||
Use this example code in your project to get
|
||||
started. Or learn more about {project.name}s in
|
||||
the{" "}
|
||||
<TertiaryA
|
||||
href={project.docsLink}
|
||||
target={"_blank"}
|
||||
className="!text-base text-slate-400 underline decoration-green-500 underline-offset-2 hover:text-white hover:decoration-green-400"
|
||||
>
|
||||
docs
|
||||
</TertiaryA>
|
||||
.
|
||||
</Body>
|
||||
<CodeBlock
|
||||
code={project.code(environment.apiKey)}
|
||||
align="top"
|
||||
/>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Group>
|
||||
<div className={classNames(maxWidth)}>
|
||||
<StepNumber stepNumber="3" />
|
||||
<div className="w-full">
|
||||
<Header4 size="regular" className={subTitle}>
|
||||
Run your web server
|
||||
</Header4>
|
||||
<Body size="regular" className="mb-4 text-slate-400">
|
||||
Run your server as you typically do, e.g.{" "}
|
||||
<InlineCode>npm run dev</InlineCode>. This will connect your
|
||||
workflow to Trigger.dev, so we can start sending you events. You
|
||||
should see some log messages in your server console (tip: you can
|
||||
turn these off by removing the{" "}
|
||||
<InlineCode>logLevel: "info"</InlineCode> from the code above).
|
||||
</Body>
|
||||
<CheckForWorkflows />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function StepNumber({
|
||||
stepNumber,
|
||||
drawLine,
|
||||
}: {
|
||||
stepNumber: string;
|
||||
drawLine?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mr-3 flex flex-col items-center justify-center">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded border border-slate-700 bg-slate-800 py-1 text-sm text-green-400 shadow">
|
||||
{stepNumber}
|
||||
</span>
|
||||
{drawLine ? (
|
||||
<div className="h-full border-l border-slate-700"></div>
|
||||
) : (
|
||||
<div className="h-full"></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckForWorkflows() {
|
||||
const fetchWorkflowCount = useTypedFetcher<typeof action>();
|
||||
|
||||
if (fetchWorkflowCount.state !== "idle") {
|
||||
return (
|
||||
<Panel>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Body size="regular" className="text-slate-300">
|
||||
Waiting for your workflow to connect...
|
||||
</Body>
|
||||
</div>
|
||||
<PrimaryButton>Connecting…</PrimaryButton>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchWorkflowCount.data === undefined) {
|
||||
return (
|
||||
<fetchWorkflowCount.Form method="post">
|
||||
<Panel>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Body size="regular" className="text-slate-300">
|
||||
Waiting for your workflow to connect…
|
||||
</Body>
|
||||
</div>
|
||||
<PrimaryButton type="submit">
|
||||
Check my workflow connection
|
||||
</PrimaryButton>
|
||||
</Panel>
|
||||
</fetchWorkflowCount.Form>
|
||||
);
|
||||
} else {
|
||||
if (fetchWorkflowCount.data.hasNewWorkflows) {
|
||||
return (
|
||||
<div>
|
||||
<Panel>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-400" />
|
||||
<Body size="regular" className="font-semibold text-slate-300">
|
||||
Great, "{fetchWorkflowCount.data.newWorkflow?.title}" is
|
||||
connected!
|
||||
</Body>
|
||||
</div>
|
||||
<PrimaryLink
|
||||
to={`../workflows/${fetchWorkflowCount.data.newWorkflow?.slug}`}
|
||||
>
|
||||
View workflow
|
||||
</PrimaryLink>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Panel>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-amber-400" />
|
||||
<Body size="regular" className="text-slate-300">
|
||||
It doesn't seem like your workflow has connected yet. Check your
|
||||
server is running and try again.
|
||||
</Body>
|
||||
</div>
|
||||
<fetchWorkflowCount.Form method="post">
|
||||
<PrimaryButton type="submit">
|
||||
Check my workflow connection
|
||||
</PrimaryButton>
|
||||
</fetchWorkflowCount.Form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +157,6 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
rules,
|
||||
externalSourceConfig,
|
||||
},
|
||||
currentEnvironmentSlug,
|
||||
connectionSlots,
|
||||
});
|
||||
};
|
||||
|
||||
+49
-25
@@ -1,3 +1,4 @@
|
||||
import { InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
import type { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
@@ -92,7 +93,8 @@ export default function Page() {
|
||||
{eventRule &&
|
||||
eventRule.trigger.type === "WEBHOOK" &&
|
||||
workflow.externalSourceConfig?.type === "manual" ? (
|
||||
<PanelInfo className="mb-6 pb-4">
|
||||
<div className="mb-6 flex w-full justify-start gap-4 rounded-md border border-slate-600 bg-slate-400/10 py-3 pl-3 pr-3 pb-4 shadow-md">
|
||||
<InformationCircleIcon className="h-6 w-6 min-w-[24px] text-blue-500" />
|
||||
{workflow.externalSourceConfig.data.success ? (
|
||||
<div className="flex flex-col">
|
||||
<Body className="mb-4">
|
||||
@@ -102,12 +104,15 @@ export default function Page() {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Body
|
||||
size="extra-small"
|
||||
className="text-slate-300 uppercase tracking-wide"
|
||||
className="uppercase tracking-wide text-slate-300"
|
||||
>
|
||||
URL
|
||||
</Body>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Input value={workflow.externalSourceConfig.data.url} />
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Input
|
||||
value={workflow.externalSourceConfig.data.url}
|
||||
readOnly={true}
|
||||
/>
|
||||
<CopyTextButton
|
||||
value={workflow.externalSourceConfig.data.url}
|
||||
></CopyTextButton>
|
||||
@@ -117,7 +122,7 @@ export default function Page() {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Body
|
||||
size="extra-small"
|
||||
className="text-slate-300 uppercase tracking-wide"
|
||||
className="uppercase tracking-wide text-slate-300"
|
||||
>
|
||||
Secret
|
||||
</Body>
|
||||
@@ -125,6 +130,7 @@ export default function Page() {
|
||||
<Input
|
||||
type="password"
|
||||
value={workflow.externalSourceConfig.data.secret}
|
||||
readOnly={true}
|
||||
/>
|
||||
<CopyTextButton
|
||||
value={workflow.externalSourceConfig.data.secret}
|
||||
@@ -134,33 +140,33 @@ export default function Page() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Body className="text-rose-500">
|
||||
Your custom webhook event is incorrectly formatted. See the
|
||||
error below
|
||||
</Body>
|
||||
<CodeBlock
|
||||
code={workflow.externalSourceConfig.data.error}
|
||||
className="border border-rose-500 w-full"
|
||||
className="w-full border border-rose-500"
|
||||
align="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelInfo>
|
||||
</div>
|
||||
) : (
|
||||
<PanelWarning className="mb-6">
|
||||
This workflow requires its APIs to be connected before it can run.
|
||||
</PanelWarning>
|
||||
<PanelWarning
|
||||
className="mb-6"
|
||||
message="This workflow requires its APIs to be connected before it can run."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{workflow.status === "DISABLED" && (
|
||||
<PanelInfo className="mb-6">
|
||||
<Body className="flex grow items-center justify-between">
|
||||
This workflow is disabled. Runs cannot be triggered or tested while
|
||||
disabled. Runs in progress will continue until complete.
|
||||
</Body>
|
||||
|
||||
<PanelInfo
|
||||
message="This workflow is disabled. Runs cannot be triggered or tested while
|
||||
disabled. Runs in progress will continue until complete."
|
||||
className="mb-6"
|
||||
>
|
||||
<TertiaryLink to="settings" className="mr-1">
|
||||
Settings
|
||||
</TertiaryLink>
|
||||
@@ -169,16 +175,16 @@ export default function Page() {
|
||||
{apiConnectionCount > 0 && <WorkflowConnections />}
|
||||
{eventRule && (
|
||||
<>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="flex items-end justify-between">
|
||||
<SubTitle>Workflow type</SubTitle>
|
||||
<SecondaryLink to="test" className="mb-2">
|
||||
Run a test
|
||||
Test workflow
|
||||
</SecondaryLink>
|
||||
</div>
|
||||
<Panel className="mb-4">
|
||||
<Panel className="mb-6">
|
||||
<PanelHeader
|
||||
icon={
|
||||
<div className="h-6 w-6 mr-1">
|
||||
<div className="mr-1 h-6 w-6">
|
||||
<TriggerTypeIcon
|
||||
type={eventRule.trigger.type}
|
||||
provider={connectionSlots.source?.integration}
|
||||
@@ -196,13 +202,13 @@ export default function Page() {
|
||||
|
||||
{total > 0 ? (
|
||||
<>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="flex items-end justify-between">
|
||||
<SubTitle>Last {pageSize} runs</SubTitle>
|
||||
<SecondaryLink to="runs" className="mb-2">
|
||||
View all
|
||||
</SecondaryLink>
|
||||
</div>
|
||||
<Panel className="p-0 overflow-hidden overflow-x-auto mb-6">
|
||||
<Panel className="mb-6 overflow-hidden overflow-x-auto p-0">
|
||||
<RunsTable
|
||||
runs={runs}
|
||||
total={total}
|
||||
@@ -213,8 +219,26 @@ export default function Page() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SubTitle>No workflows run yet</SubTitle>
|
||||
<PrimaryLink to="test">Test your workflow</PrimaryLink>
|
||||
<SubTitle>Workflow runs</SubTitle>
|
||||
<PanelWarning
|
||||
message="This workflow hasn't been run yet. Test it to view runs here."
|
||||
className="flex justify-between"
|
||||
>
|
||||
<PrimaryLink to="test">Test your workflow</PrimaryLink>
|
||||
</PanelWarning>
|
||||
{/* <Panel className="flex flex-col items-start gap-2 p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-amber-400" />
|
||||
<Header3>This workflow hasn't been run yet</Header3>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Body className="mt-1 mb-2 text-slate-300">
|
||||
If you want to quickly test the workflow, you can use the test
|
||||
feature.
|
||||
</Body>
|
||||
<PrimaryLink to="test">Test your workflow</PrimaryLink>
|
||||
</div>
|
||||
</Panel> */}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
+9
-8
@@ -21,17 +21,18 @@ export default function Page() {
|
||||
<Title>Connected APIs</Title>
|
||||
{workflow.status === "CREATED" && (
|
||||
<>
|
||||
<PanelWarning className="mb-6">
|
||||
This workflow requires its APIs to be connected before it can run.
|
||||
</PanelWarning>
|
||||
<PanelWarning
|
||||
message="This workflow requires its APIs to be connected before it can run."
|
||||
className="mb-6"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{workflow.status === "DISABLED" && (
|
||||
<PanelInfo className="mb-6">
|
||||
<Body className="flex grow items-center justify-between">
|
||||
This workflow is disabled. Runs cannot be triggered or tested while
|
||||
disabled. Runs in progress will continue until complete.
|
||||
</Body>
|
||||
<PanelInfo
|
||||
message="This workflow is disabled. Runs cannot be triggered or tested while
|
||||
disabled. Runs in progress will continue until complete."
|
||||
className="mb-6"
|
||||
>
|
||||
<TertiaryLink to="settings" className="mr-1">
|
||||
Settings
|
||||
</TertiaryLink>
|
||||
|
||||
+75
-49
@@ -6,6 +6,7 @@ import {
|
||||
ChevronUpIcon,
|
||||
ClockIcon,
|
||||
GlobeAltIcon,
|
||||
KeyIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ArrowPathRoundedSquareIcon,
|
||||
@@ -82,15 +83,15 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex sticky -top-12 py-4 -mt-4 -ml-1 pl-1 bg-slate-850 justify-between items-center z-10">
|
||||
<div className="sticky -top-12 z-10 -mt-4 -ml-1 flex items-center justify-between bg-slate-850 py-4 pl-1">
|
||||
<Header1 className="truncate text-slate-300">Run {run.id}</Header1>
|
||||
<div className="flex gap-2">
|
||||
{run.isTest && (
|
||||
<Body
|
||||
size="extra-small"
|
||||
className="flex items-center pl-2 pr-3 py-0.5 rounded uppercase whitespace-nowrap tracking-wide text-slate-500"
|
||||
className="flex items-center whitespace-nowrap rounded py-0.5 pl-2 pr-3 uppercase tracking-wide text-slate-500"
|
||||
>
|
||||
<BeakerIcon className="h-4 w-4 mr-1" />
|
||||
<BeakerIcon className="mr-1 h-4 w-4" />
|
||||
Test Run
|
||||
</Body>
|
||||
)}
|
||||
@@ -122,28 +123,28 @@ export default function Page() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowPathRoundedSquareIcon className="h-5 w-5 -ml-1" />
|
||||
<ArrowPathRoundedSquareIcon className="-ml-1 h-5 w-5" />
|
||||
Rerun
|
||||
</PrimaryButton>
|
||||
</rerunFetcher.Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex gap-6 ml-[-3px] flex-wrap">
|
||||
<li className="flex gap-2 items-center">
|
||||
<ul className="ml-[-3px] flex flex-wrap gap-6">
|
||||
<li className="flex items-center gap-2">
|
||||
{runStatusIcon(run.status, "large")}
|
||||
<Header2 size="small" className="text-slate-400">
|
||||
{runStatusLabel(run.status)}
|
||||
</Header2>
|
||||
</li>
|
||||
<li className="flex gap-1 items-center">
|
||||
<li className="flex items-center gap-1">
|
||||
<Header2 size="small" className="text-slate-400">
|
||||
{run.startedAt &&
|
||||
`Started: ${formatDateTime(run.startedAt, "long")}`}
|
||||
</Header2>
|
||||
</li>
|
||||
{run.duration && (
|
||||
<li className="flex gap-1 items-center">
|
||||
<li className="flex items-center gap-1">
|
||||
<Header2 size="small" className="text-slate-400">
|
||||
Duration: {humanizeDuration(run.duration)}
|
||||
</Header2>
|
||||
@@ -161,7 +162,7 @@ export default function Page() {
|
||||
|
||||
{run.status === "SUCCESS" && (
|
||||
<>
|
||||
<div className="h-3 w-full ml-[10px] -mr-[10px] border-l border-slate-700"></div>
|
||||
<div className="ml-[10px] -mr-[10px] h-3 w-full border-l border-slate-700"></div>
|
||||
<Panel>
|
||||
<PanelHeader
|
||||
icon={<CheckCircleIcon className="h-6 w-6 text-green-500" />}
|
||||
@@ -217,12 +218,12 @@ export default function Page() {
|
||||
)}
|
||||
|
||||
{run.status === "RUNNING" && (
|
||||
<div className="h-10 w-full ml-[10px] border-dashed border-l border-gradient border-slate-700"></div>
|
||||
<div className="border-gradient ml-[10px] h-10 w-full border-l border-dashed border-slate-700"></div>
|
||||
)}
|
||||
|
||||
{run.status === "TIMED_OUT" && (
|
||||
<>
|
||||
<div className="h-3 w-full ml-[10px] -mr-[10px] border-l border-slate-700"></div>
|
||||
<div className="ml-[10px] -mr-[10px] h-3 w-full border-l border-slate-700"></div>
|
||||
<Panel>
|
||||
<PanelHeader
|
||||
icon={
|
||||
@@ -262,7 +263,7 @@ function TriggerStep({ trigger }: { trigger: Trigger }) {
|
||||
<Panel className="mt-4">
|
||||
<PanelHeader
|
||||
icon={
|
||||
<div className="h-6 w-6 mr-1">
|
||||
<div className="mr-1 h-6 w-6">
|
||||
<TriggerTypeIcon
|
||||
type={trigger.type}
|
||||
provider={trigger.integration}
|
||||
@@ -285,7 +286,7 @@ function TriggerStep({ trigger }: { trigger: Trigger }) {
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
<div className="h-3 w-full ml-[10px] -mr-[10px] border-l border-slate-700"></div>
|
||||
<div className="ml-[10px] -mr-[10px] h-3 w-full border-l border-slate-700"></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -298,16 +299,16 @@ function WorkflowStep({ step }: { step: Step }) {
|
||||
switch (step.type) {
|
||||
case "DISCONNECTION":
|
||||
return (
|
||||
<div className="flex items-stretch w-full">
|
||||
<div className="relative flex w-5 border-l border-dashed border-slate-700 ml-2.5">
|
||||
<div className="absolute top-2 -left-[18px] p-1 bg-slate-850 rounded-full">
|
||||
<div className="flex w-full items-stretch">
|
||||
<div className="relative ml-2.5 flex w-5 border-l border-dashed border-slate-700">
|
||||
<div className="absolute top-2 -left-[18px] rounded-full bg-slate-850 p-1">
|
||||
{runStatusIcon("DISCONNECTED", "large")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Body
|
||||
size="small"
|
||||
className={classNames("font-mono my-4 ml-0.5 text-slate-400")}
|
||||
className={classNames("my-4 ml-0.5 font-mono text-slate-400")}
|
||||
>
|
||||
{step.startedAt && step.finishedAt
|
||||
? `The run disconnected for ${humanizeDuration(
|
||||
@@ -325,16 +326,16 @@ function WorkflowStep({ step }: { step: Step }) {
|
||||
);
|
||||
case "LOG_MESSAGE":
|
||||
return (
|
||||
<div className="flex items-stretch w-full">
|
||||
<div className="relative flex shrink-0 w-5 border-l border-slate-700 ml-2.5">
|
||||
<div className="absolute top-2 -left-[18px] p-1 bg-slate-850 rounded-full">
|
||||
<div className="flex w-full items-stretch">
|
||||
<div className="relative ml-2.5 flex w-5 shrink-0 border-l border-slate-700">
|
||||
<div className="absolute top-2 -left-[18px] rounded-full bg-slate-850 p-1">
|
||||
<ChatBubbleOvalLeftEllipsisIcon
|
||||
className={classNames("h-7 w-7", logColor[step.input.level])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full my-4">
|
||||
<div className={classNames("flex gap-2 items-center")}>
|
||||
<div className="my-4 flex w-full flex-col gap-2">
|
||||
<div className={classNames("flex items-center gap-2")}>
|
||||
<Body
|
||||
size="small"
|
||||
className={classNames(
|
||||
@@ -349,14 +350,14 @@ function WorkflowStep({ step }: { step: Step }) {
|
||||
Object.keys(step.input.properties).length !== 0 && (
|
||||
<button
|
||||
onClick={toggleCodeBlock}
|
||||
className="text-sm text-slate-400 hover:text-slate-200 transition"
|
||||
className="text-sm text-slate-400 transition hover:text-slate-200"
|
||||
>
|
||||
{showCodeBlock ? (
|
||||
<span className="flex gap-1 items-center">
|
||||
<span className="flex items-center gap-1">
|
||||
Hide custom fields <ChevronUpIcon className="h-4 w-4" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex gap-1 items-center">
|
||||
<span className="flex items-center gap-1">
|
||||
View custom fields{" "}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</span>
|
||||
@@ -379,9 +380,9 @@ function WorkflowStep({ step }: { step: Step }) {
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex items-stretch w-full">
|
||||
<div className="relative flex shrink-0 w-5 border-l border-slate-700 ml-2.5">
|
||||
<div className="absolute top-[23px] -left-[18px] p-1 bg-slate-850 rounded-full">
|
||||
<div className="flex w-full items-stretch">
|
||||
<div className="relative ml-2.5 flex w-5 shrink-0 border-l border-slate-700">
|
||||
<div className="absolute top-[23px] -left-[18px] rounded-full bg-slate-850 p-1">
|
||||
{runStatusIcon(step.status, "large")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,7 +424,7 @@ function StepHeader({ step }: { step: Step }) {
|
||||
<img
|
||||
src={step.service.integration.icon}
|
||||
alt={step.service.integration.name}
|
||||
className="h-5 w-5 mr-1"
|
||||
className="mr-1 h-5 w-5"
|
||||
/>
|
||||
}
|
||||
title={step.service.integration.name}
|
||||
@@ -473,6 +474,8 @@ function StepBody({ step }: { step: Step }) {
|
||||
switch (step.type) {
|
||||
case "CUSTOM_EVENT":
|
||||
return <CustomEventStep event={step} />;
|
||||
case "RUN_ONCE":
|
||||
return <RunOnceStep event={step} />;
|
||||
case "INTEGRATION_REQUEST":
|
||||
return <IntegrationRequestStep request={step} />;
|
||||
case "FETCH_REQUEST":
|
||||
@@ -584,7 +587,7 @@ function DelayScheduled({
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 text-slate-300">
|
||||
<div className="flex flex-col gap-1 items-stretch">
|
||||
<div className="flex flex-col items-stretch gap-1">
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Fires at
|
||||
</Body>
|
||||
@@ -621,7 +624,7 @@ function CustomEventStep({ event }: { event: StepType<Step, "CUSTOM_EVENT"> }) {
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Name
|
||||
</Body>
|
||||
<Header2 size="small" className="text-slate-300 mb-2">
|
||||
<Header2 size="small" className="mb-2 text-slate-300">
|
||||
{event.input.name}
|
||||
</Header2>
|
||||
{"delay" in event.input && event.input.delay && (
|
||||
@@ -629,7 +632,7 @@ function CustomEventStep({ event }: { event: StepType<Step, "CUSTOM_EVENT"> }) {
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Delay
|
||||
</Body>
|
||||
<Body size="small" className="text-slate-300 mb-2">
|
||||
<Body size="small" className="mb-2 text-slate-300">
|
||||
{"seconds" in event.input.delay ? (
|
||||
<>
|
||||
{event.input.delay.seconds}{" "}
|
||||
@@ -670,6 +673,25 @@ function CustomEventStep({ event }: { event: StepType<Step, "CUSTOM_EVENT"> }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RunOnceStep({ event }: { event: StepType<Step, "RUN_ONCE"> }) {
|
||||
return (
|
||||
<>
|
||||
<Body size="extra-small" className={workflowNodeUppercaseClasses}>
|
||||
Idempotency Key
|
||||
</Body>
|
||||
<Header2 size="small" className="mb-2 text-slate-300">
|
||||
{event.idempotencyKey}
|
||||
</Header2>
|
||||
{event.output && (
|
||||
<>
|
||||
<Header4>Output</Header4>
|
||||
<CodeBlock code={stringifyCode(event.output)} align="top" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationRequestStep({
|
||||
request,
|
||||
}: {
|
||||
@@ -684,14 +706,14 @@ function IntegrationRequestStep({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header2 size="small" className="text-slate-300 mb-2">
|
||||
<Header2 size="small" className="mb-2 text-slate-300">
|
||||
{request.displayProperties.title}
|
||||
</Header2>
|
||||
{request.service.connection === null && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-md bg-rose-500/10 border border-rose-600 p-3 mb-2">
|
||||
<ExclamationCircleIcon className="h-6 w-6 mr-1 text-rose-500" />
|
||||
<div className="flex gap-2 items-center justify-between flex-wrap w-full">
|
||||
<div className="mb-2 flex items-center gap-2 rounded-md border border-rose-600 bg-rose-500/10 p-3">
|
||||
<ExclamationCircleIcon className="mr-1 h-6 w-6 text-rose-500" />
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-2">
|
||||
<Body>
|
||||
You need to connect to {request.service.integration.name} to
|
||||
continue this workflow.
|
||||
@@ -723,7 +745,7 @@ function IntegrationRequestStep({
|
||||
<div className="mt-4">
|
||||
{request.requestStatus === "ERROR" ? (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-2 mt-3 ">
|
||||
<div className="mb-2 mt-3 flex gap-2 ">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-rose-500" />
|
||||
<Body size="small" className="text-rose-500">
|
||||
Failed with error:
|
||||
@@ -793,7 +815,7 @@ function FetchRequestStep({
|
||||
<div className="mt-4">
|
||||
{request.requestStatus === "ERROR" ? (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-2 mt-3 ">
|
||||
<div className="mb-2 mt-3 flex gap-2 ">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-rose-500" />
|
||||
<Body size="small" className="text-rose-500">
|
||||
Failed with error:
|
||||
@@ -808,7 +830,7 @@ function FetchRequestStep({
|
||||
</div>
|
||||
) : request.requestStatus === "RETRYING" ? (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-2 mt-3 ">
|
||||
<div className="mb-2 mt-3 flex gap-2 ">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-rose-500" />
|
||||
<Body size="small" className="text-rose-500">
|
||||
{request.lastResponse ? (
|
||||
@@ -856,7 +878,7 @@ function Error({ error }: { error: Run["error"] }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 mb-2 mt-3 ">
|
||||
<div className="mb-2 mt-3 flex gap-2 ">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-rose-500" />
|
||||
<Body size="small" className="text-slate-300">
|
||||
Failed with error:
|
||||
@@ -903,6 +925,10 @@ const stepInfo: Record<Step["type"], { label: string; icon: ReactNode }> = {
|
||||
label: "Fetch request",
|
||||
icon: <GlobeAltIcon className={styleClass} />,
|
||||
},
|
||||
RUN_ONCE: {
|
||||
label: "Run once",
|
||||
icon: <KeyIcon className={styleClass} />,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type LogLevel = StepType<Step, "LOG_MESSAGE">["input"]["level"];
|
||||
@@ -921,16 +947,16 @@ function renderCustomComponent({
|
||||
input: z.infer<typeof resendSchemas.SendEmailBodySchema>;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-md">
|
||||
<div className="flex px-2 h-8 items-center bg-slate-100 rounded-t-md">
|
||||
<div className="flex h-8 gap-2 items-center bg-slate-100 rounded-t-md">
|
||||
<div className="rounded-full bg-rose-500 w-3 h-3"></div>
|
||||
<div className="rounded-full bg-orange-500 w-3 h-3"></div>
|
||||
<div className="rounded-full bg-emerald-500 w-3 h-3"></div>
|
||||
<div className="rounded-md bg-white">
|
||||
<div className="flex h-8 items-center rounded-t-md bg-slate-100 px-2">
|
||||
<div className="flex h-8 items-center gap-2 rounded-t-md bg-slate-100">
|
||||
<div className="h-3 w-3 rounded-full bg-rose-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-orange-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-2 border-b border-slate-300">
|
||||
<h2 className="text-lg text-slate-600 font-bold">{input.from}</h2>
|
||||
<div className="border-b border-slate-300 px-4 py-2">
|
||||
<h2 className="text-lg font-bold text-slate-600">{input.from}</h2>
|
||||
<h2 className="text-slate-600">{input.subject}</h2>
|
||||
<div className="flex gap-2">
|
||||
<EmailInfo label="to" value={input.to} />
|
||||
@@ -966,7 +992,7 @@ function EmailInfo({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-slate-500 text-sm flex items-baseline gap-2">
|
||||
<div className="flex items-baseline gap-2 text-sm text-slate-500">
|
||||
<h3 className="text-slate-400">{label}:</h3>
|
||||
{typeof value === "string" ? value : value.join(", ")}
|
||||
</div>
|
||||
|
||||
+12
-11
@@ -81,17 +81,18 @@ export default function Page() {
|
||||
<Title>Runs</Title>
|
||||
{workflow.status === "CREATED" && (
|
||||
<>
|
||||
<PanelWarning className="mb-6">
|
||||
This workflow requires its APIs to be connected before it can run.
|
||||
</PanelWarning>
|
||||
<PanelWarning
|
||||
message="This workflow requires its APIs to be connected before it can run."
|
||||
className="mb-6"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{workflow.status === "DISABLED" && (
|
||||
<PanelInfo className="mb-6">
|
||||
<Body className="flex grow items-center justify-between">
|
||||
This workflow is disabled. Runs cannot be triggered or tested while
|
||||
disabled. Runs in progress will continue until complete.
|
||||
</Body>
|
||||
<PanelInfo
|
||||
message="This workflow is disabled. Runs cannot be triggered or tested while
|
||||
disabled. Runs in progress will continue until complete."
|
||||
className="mb-6"
|
||||
>
|
||||
<TertiaryLink to="settings" className="mr-1">
|
||||
Settings
|
||||
</TertiaryLink>
|
||||
@@ -99,7 +100,7 @@ export default function Page() {
|
||||
)}
|
||||
<fetcher.Form
|
||||
method="get"
|
||||
className="pb-4 flex gap-2"
|
||||
className="flex gap-2 pb-4"
|
||||
onChange={submitForm}
|
||||
ref={formRef}
|
||||
>
|
||||
@@ -107,7 +108,7 @@ export default function Page() {
|
||||
</fetcher.Form>
|
||||
<Panel
|
||||
className={classNames(
|
||||
"p-0 overflow-hidden overflow-x-auto",
|
||||
"overflow-hidden overflow-x-auto p-0",
|
||||
total === 0 ? "rounded-b-lg" : "rounded-b-none"
|
||||
)}
|
||||
>
|
||||
@@ -172,7 +173,7 @@ function StatusFilter({
|
||||
<span
|
||||
className={classNames(
|
||||
selected ? "" : "",
|
||||
"flex truncate font-semibold items-center gap-1"
|
||||
"flex items-center gap-1 truncate font-semibold"
|
||||
)}
|
||||
>
|
||||
{runStatusIcon(status, "small")}
|
||||
|
||||
+9
-5
@@ -12,7 +12,7 @@ import type { CurrentWorkflow } from "~/hooks/useWorkflows";
|
||||
import { useCurrentWorkflow } from "~/hooks/useWorkflows";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Form } from "@remix-run/react";
|
||||
import type { ActionArgs } from "@remix-run/server-runtime";
|
||||
import type { ActionArgs, LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
@@ -24,6 +24,10 @@ import { DisableWorkflow } from "~/services/workflows/disableWorkflow.server";
|
||||
import { EnableWorkflow } from "~/services/workflows/enableWorkflow.server";
|
||||
import { ArchiveWorkflow } from "~/services/workflows/archiveWorkflow.server";
|
||||
import { UnarchiveWorkflow } from "~/services/workflows/unarchiveWorkflow.server";
|
||||
import { PanelWarning } from "~/components/layout/PanelWarning";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
|
||||
import { WorkflowTestPresenter } from "~/presenters/testPresenter.server";
|
||||
|
||||
const ActionSchema = z.enum(["disable", "enable", "archive", "unarchive"]);
|
||||
const FormSchema = z.object({
|
||||
@@ -135,7 +139,7 @@ export default function Page() {
|
||||
invariant(workflow, "Workflow not found");
|
||||
|
||||
const panel =
|
||||
workflow.status === "READY" ? (
|
||||
workflow.status === "READY" || workflow.status === "CREATED" ? (
|
||||
<WorkflowReadyPanel workflow={workflow} />
|
||||
) : workflow.isArchived ? (
|
||||
<WorkflowArchivedPanel workflow={workflow} />
|
||||
@@ -159,7 +163,7 @@ function WorkflowReadyPanel({
|
||||
}) {
|
||||
return (
|
||||
<Panel className="flex items-center justify-between !p-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<ApiLogoIcon size="regular" />
|
||||
<Header3 size="small" className="text-slate-300">
|
||||
{workflow.title} <span className="text-green-500">is active.</span>
|
||||
@@ -203,7 +207,7 @@ function WorkflowDisabledPanel({
|
||||
}) {
|
||||
return (
|
||||
<Panel className="flex items-center justify-between !p-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<ApiLogoIcon size="regular" />
|
||||
<Header3 size="small" className="text-slate-300">
|
||||
{workflow.title} <span className="text-amber-300">is disabled.</span>
|
||||
@@ -247,7 +251,7 @@ function WorkflowArchivedPanel({
|
||||
}) {
|
||||
return (
|
||||
<Panel className="flex items-center justify-between !p-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<ApiLogoIcon size="regular" />
|
||||
<Header3 size="small" className="text-slate-300">
|
||||
{workflow.title} <span className="text-rose-500">is archived.</span>
|
||||
|
||||
+23
-29
@@ -13,7 +13,8 @@ import { Body } from "~/components/primitives/text/Body";
|
||||
import { Title } from "~/components/primitives/text/Title";
|
||||
import { useCurrentOrganization } from "~/hooks/useOrganizations";
|
||||
import { useCurrentWorkflow } from "~/hooks/useWorkflows";
|
||||
import { getMostRecentWorkflowRun } from "~/models/workflowRun.server";
|
||||
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
|
||||
import { WorkflowTestPresenter } from "~/presenters/testPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
@@ -22,12 +23,14 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
invariant(workflowSlug, "workflowSlug is required");
|
||||
invariant(organizationSlug, "organizationSlug is required");
|
||||
|
||||
const environmentSlug = await getRuntimeEnvironmentFromRequest(request);
|
||||
|
||||
try {
|
||||
const latestRun = await getMostRecentWorkflowRun({
|
||||
workflowSlug,
|
||||
organizationSlug,
|
||||
});
|
||||
return typedjson({ latestRun });
|
||||
const presenter = new WorkflowTestPresenter();
|
||||
|
||||
return typedjson(
|
||||
await presenter.data({ workflowSlug, organizationSlug, environmentSlug })
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
throw new Response("Error ", { status: 400 });
|
||||
@@ -35,7 +38,7 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { latestRun } = useTypedLoaderData<typeof loader>();
|
||||
const { payload, status } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const organization = useCurrentOrganization();
|
||||
invariant(organization, "Organization not found");
|
||||
@@ -45,38 +48,29 @@ export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<Title>Test</Title>
|
||||
{workflow.status === "CREATED" && (
|
||||
<>
|
||||
<PanelWarning className="mb-6">
|
||||
This workflow requires its APIs to be connected before it can run.
|
||||
</PanelWarning>
|
||||
</>
|
||||
{status === "CREATED" && (
|
||||
<PanelWarning
|
||||
message="This workflow requires its APIs to be connected before it can run."
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
{workflow.status === "DISABLED" && (
|
||||
<PanelInfo className="mb-6">
|
||||
<Body className="flex grow items-center justify-between">
|
||||
This workflow is disabled. Runs cannot be triggered or tested while
|
||||
disabled. Runs in progress will continue until complete.
|
||||
</Body>
|
||||
{status === "DISABLED" ? (
|
||||
<PanelInfo
|
||||
message="This workflow is disabled. Runs cannot be triggered or tested while
|
||||
disabled. Runs in progress will continue until complete."
|
||||
className="mb-6"
|
||||
>
|
||||
<TertiaryLink to="settings" className="mr-1">
|
||||
Settings
|
||||
</TertiaryLink>
|
||||
</PanelInfo>
|
||||
)}
|
||||
|
||||
{workflow.status === "READY" && (
|
||||
) : (
|
||||
<Panel className="mt-4">
|
||||
<Tester
|
||||
organizationSlug={organization.slug}
|
||||
workflowSlug={workflow.slug}
|
||||
eventNames={workflow.eventNames}
|
||||
initialValue={
|
||||
workflow.type === "SCHEDULE"
|
||||
? JSON.stringify({ scheduledTime: new Date() }, null, 2)
|
||||
: latestRun == null
|
||||
? "{\n\n}"
|
||||
: JSON.stringify(latestRun.event.payload, null, 2)
|
||||
}
|
||||
initialValue={JSON.stringify(payload, null, 2)}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import type { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { getApiConnectionsForWorkflow } from "~/models/apiConnection.server";
|
||||
import { getAccessInfo } from "~/services/accessInfo.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
serviceIdentifier: z.string(),
|
||||
workflowId: z.string(),
|
||||
mediaId: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderArgs) {
|
||||
const { serviceIdentifier, workflowId, mediaId } = paramsSchema.parse(params);
|
||||
|
||||
const connections = await getApiConnectionsForWorkflow({ workflowId });
|
||||
const relevantConnection = connections.find(
|
||||
(c) => c.apiIdentifier === serviceIdentifier
|
||||
);
|
||||
if (!relevantConnection) {
|
||||
return {
|
||||
status: 404,
|
||||
body: `Could not find connection with serviceIdentifier ${serviceIdentifier}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (relevantConnection.status !== "CONNECTED") {
|
||||
return {
|
||||
status: 500,
|
||||
body: `Connection with serviceIdentifier ${serviceIdentifier} is not connected`,
|
||||
};
|
||||
}
|
||||
|
||||
const accessInfo = await getAccessInfo(relevantConnection);
|
||||
|
||||
switch (serviceIdentifier) {
|
||||
case "whatsapp": {
|
||||
if (accessInfo?.type !== "api_key") {
|
||||
return { status: 500, body: "Invalid access info" };
|
||||
}
|
||||
|
||||
const accessToken = accessInfo.api_key;
|
||||
const mediaUrlResponse = await fetch(
|
||||
`https://graph.facebook.com/v15.0/${mediaId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!mediaUrlResponse.ok) {
|
||||
return {
|
||||
status: mediaUrlResponse.status,
|
||||
body: await mediaUrlResponse.text(),
|
||||
};
|
||||
}
|
||||
|
||||
const json = await mediaUrlResponse.json();
|
||||
|
||||
if (!json.url) {
|
||||
return {
|
||||
status: 500,
|
||||
body: `Could not find url in response: ${JSON.stringify(json)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const media = await fetch(json.url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!media.ok) {
|
||||
return {
|
||||
status: media.status,
|
||||
body: await media.text(),
|
||||
};
|
||||
}
|
||||
|
||||
return new Response(media.body, {
|
||||
headers: {
|
||||
"Content-Type": json.mime_type,
|
||||
"Content-Disposition": `attachment;`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 500,
|
||||
body: `Unknown serviceIdentifier ${serviceIdentifier}`,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,65 @@
|
||||
import type { ActionArgs } from "@remix-run/server-runtime";
|
||||
import type { ActionArgs, LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { findExternalSourceById } from "~/models/externalSource.server";
|
||||
import { HandleExternalSource } from "~/services/externalSources/handleExternalSource.server";
|
||||
import { VerifyExternalSource } from "~/services/externalSources/verifyExternalSource.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string(),
|
||||
serviceIdentifier: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderArgs) {
|
||||
const { id, serviceIdentifier } = paramsSchema.parse(params);
|
||||
|
||||
const externalSource = await findExternalSourceById(id);
|
||||
|
||||
if (!externalSource) {
|
||||
return {
|
||||
status: 404,
|
||||
body: `Could not find external source with id ${id} and serviceIdentifier ${serviceIdentifier}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!externalSource.manualRegistration &&
|
||||
externalSource.connection?.apiIdentifier !== serviceIdentifier
|
||||
) {
|
||||
return { status: 500, body: "Service identifier does not match" };
|
||||
}
|
||||
|
||||
try {
|
||||
const service = new VerifyExternalSource();
|
||||
const result = await service.call(
|
||||
externalSource,
|
||||
serviceIdentifier,
|
||||
request
|
||||
);
|
||||
|
||||
switch (result.status) {
|
||||
case "ok": {
|
||||
return new Response(result.data, { status: 200 });
|
||||
}
|
||||
case "ignored": {
|
||||
return new Response(result.reason, { status: 200 });
|
||||
}
|
||||
case "error": {
|
||||
return new Response(result.error, { status: 500 });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 500,
|
||||
body:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Unknown error: ${JSON.stringify(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionArgs) {
|
||||
const { id, serviceIdentifier } = z
|
||||
.object({ id: z.string(), serviceIdentifier: z.string() })
|
||||
.parse(params);
|
||||
const { id, serviceIdentifier } = paramsSchema.parse(params);
|
||||
|
||||
const externalSource = await findExternalSourceById(id);
|
||||
|
||||
|
||||
@@ -15,10 +15,13 @@ export async function action({ request }: ActionArgs) {
|
||||
const service = new HandleSlackInteractivity();
|
||||
|
||||
try {
|
||||
await service.call(parsedPayload);
|
||||
return await service.call(parsedPayload);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return { status: 200 };
|
||||
return new Response(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export class DispatchEvent {
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
throw new Error("Event not found");
|
||||
throw new Error(`Event not found: ${id}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -201,6 +201,7 @@ export class DispatchWorkflowRun {
|
||||
});
|
||||
|
||||
if (
|
||||
!workflowRun.isTest &&
|
||||
workflowRun.workflow.externalSource &&
|
||||
workflowRun.workflow.externalSource.status === "CREATED"
|
||||
) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import * as github from "@trigger.dev/github/internal";
|
||||
import type { ExternalSourceWithConnection } from "~/models/externalSource.server";
|
||||
import type { NormalizedRequest } from "@trigger.dev/integration-sdk";
|
||||
import { IngestEvent } from "../events/ingest.server";
|
||||
import { ManualWebhookSourceSchema } from "@trigger.dev/common-schemas";
|
||||
import * as github from "@trigger.dev/github/internal";
|
||||
import type { NormalizedRequest } from "@trigger.dev/integration-sdk";
|
||||
import * as whatsapp from "@trigger.dev/whatsapp/internal";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { ulid } from "ulid";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import type { ExternalSourceWithConnection } from "~/models/externalSource.server";
|
||||
import { IngestEvent } from "../events/ingest.server";
|
||||
import { createNormalizedRequest } from "./utils";
|
||||
|
||||
type IgnoredEventResponse = {
|
||||
status: "ignored";
|
||||
@@ -26,7 +28,7 @@ type TriggeredEventResponse = {
|
||||
event: string;
|
||||
timestamp?: string;
|
||||
context?: any;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
export type HandledExternalEventResponse =
|
||||
@@ -41,35 +43,12 @@ export class HandleExternalSource {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async #createNormalizedRequest(request: Request): Promise<NormalizedRequest> {
|
||||
const requestUrl = new URL(request.url);
|
||||
const rawSearchParams = requestUrl.searchParams;
|
||||
const rawBody = await request.text();
|
||||
const rawHeaders = Object.fromEntries(request.headers.entries());
|
||||
|
||||
return {
|
||||
rawBody,
|
||||
body: this.#safeJsonParse(rawBody),
|
||||
headers: rawHeaders,
|
||||
searchParams: rawSearchParams,
|
||||
};
|
||||
}
|
||||
|
||||
#safeJsonParse(json: string): any {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async call(
|
||||
externalSource: NonNullable<ExternalSourceWithConnection>,
|
||||
serviceIdentifier: string,
|
||||
request: Request
|
||||
) {
|
||||
const normalizedRequest = await this.#createNormalizedRequest(request);
|
||||
|
||||
const normalizedRequest = await createNormalizedRequest(request);
|
||||
const possibleEvent = await this.#handleExternalSource(
|
||||
externalSource,
|
||||
serviceIdentifier,
|
||||
@@ -78,28 +57,30 @@ export class HandleExternalSource {
|
||||
|
||||
switch (possibleEvent.status) {
|
||||
case "ok": {
|
||||
const { id, payload, event, timestamp, context } = possibleEvent.data;
|
||||
for (let index = 0; index < possibleEvent.data.length; index++) {
|
||||
const { id, payload, event, timestamp, context } =
|
||||
possibleEvent.data[index];
|
||||
|
||||
const ingestService = new IngestEvent();
|
||||
const ingestService = new IngestEvent();
|
||||
|
||||
await ingestService.call(
|
||||
{
|
||||
id,
|
||||
payload,
|
||||
name: event,
|
||||
type: externalSource.type,
|
||||
service: serviceIdentifier,
|
||||
timestamp,
|
||||
context,
|
||||
},
|
||||
externalSource.organization
|
||||
);
|
||||
await ingestService.call(
|
||||
{
|
||||
id,
|
||||
payload,
|
||||
name: event,
|
||||
type: externalSource.type,
|
||||
service: serviceIdentifier,
|
||||
timestamp,
|
||||
context,
|
||||
},
|
||||
externalSource.organization
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
case "ignored": {
|
||||
console.log(`Ignored external event: ${possibleEvent.reason}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
case "error": {
|
||||
@@ -135,6 +116,21 @@ export class HandleExternalSource {
|
||||
serviceIdentifier: string,
|
||||
request: NormalizedRequest
|
||||
): Promise<HandledExternalEventResponse> {
|
||||
switch (serviceIdentifier) {
|
||||
case "github": {
|
||||
return github.internalIntegration.webhooks!.handleWebhookRequest({
|
||||
request,
|
||||
secret: externalSource.secret ?? undefined,
|
||||
});
|
||||
}
|
||||
case "whatsapp": {
|
||||
return whatsapp.internalIntegration.webhooks!.handleWebhookRequest({
|
||||
request,
|
||||
secret: externalSource.secret ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (externalSource.manualRegistration) {
|
||||
return this.#handleManualWebhook(
|
||||
externalSource,
|
||||
@@ -143,19 +139,10 @@ export class HandleExternalSource {
|
||||
);
|
||||
}
|
||||
|
||||
switch (serviceIdentifier) {
|
||||
case "github": {
|
||||
return github.internalIntegration.webhooks!.handleWebhookRequest({
|
||||
request,
|
||||
secret: externalSource.secret ?? undefined,
|
||||
});
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
`Could not handle webhook with unsupported service identifier: ${serviceIdentifier}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: "ignored" as const,
|
||||
reason: `Could not handle external source with unsupported service: ${serviceIdentifier}`,
|
||||
};
|
||||
}
|
||||
|
||||
async #handleManualWebhook(
|
||||
@@ -190,15 +177,17 @@ export class HandleExternalSource {
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
data: {
|
||||
id: ulid(),
|
||||
payload: request.body,
|
||||
event: source.event,
|
||||
context: {
|
||||
headers: request.headers,
|
||||
externalSourceId: externalSource.id,
|
||||
data: [
|
||||
{
|
||||
id: ulid(),
|
||||
payload: request.body,
|
||||
event: source.event,
|
||||
context: {
|
||||
headers: request.headers,
|
||||
externalSourceId: externalSource.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
HTTPMethod,
|
||||
NormalizedRequest,
|
||||
} from "@trigger.dev/integration-sdk";
|
||||
import { httpMethods } from "@trigger.dev/integration-sdk";
|
||||
|
||||
export async function createNormalizedRequest(
|
||||
request: Request
|
||||
): Promise<NormalizedRequest> {
|
||||
const requestUrl = new URL(request.url);
|
||||
const rawSearchParams = requestUrl.searchParams;
|
||||
const rawBody = await request.text();
|
||||
const rawHeaders = Object.fromEntries(request.headers.entries());
|
||||
|
||||
if (!isMethod(request.method)) {
|
||||
throw new Error(`Invalid method: ${request.method}`);
|
||||
}
|
||||
|
||||
return {
|
||||
rawBody,
|
||||
body: safeJsonParse(rawBody),
|
||||
headers: rawHeaders,
|
||||
searchParams: rawSearchParams,
|
||||
method: request.method,
|
||||
};
|
||||
}
|
||||
|
||||
function safeJsonParse(json: string): any {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isMethod(str: string): str is HTTPMethod {
|
||||
return !!httpMethods.find((method) => str === method);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as github from "@trigger.dev/github/internal";
|
||||
import type { NormalizedRequest } from "@trigger.dev/integration-sdk";
|
||||
import * as whatsapp from "@trigger.dev/whatsapp/internal";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import type { ExternalSourceWithConnection } from "~/models/externalSource.server";
|
||||
import { createNormalizedRequest } from "./utils";
|
||||
|
||||
type ErrorEventResponse = {
|
||||
status: "error";
|
||||
error: string;
|
||||
};
|
||||
type IgnoredEventResponse = {
|
||||
status: "ignored";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type TriggeredEventResponse = {
|
||||
status: "ok";
|
||||
data: any;
|
||||
};
|
||||
|
||||
export type VerifyExternalEventResponse =
|
||||
| TriggeredEventResponse
|
||||
| IgnoredEventResponse
|
||||
| ErrorEventResponse;
|
||||
|
||||
export class VerifyExternalSource {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(
|
||||
externalSource: NonNullable<ExternalSourceWithConnection>,
|
||||
serviceIdentifier: string,
|
||||
request: Request
|
||||
): Promise<VerifyExternalEventResponse> {
|
||||
const normalizedRequest = await createNormalizedRequest(request);
|
||||
return this.#verifyExternalSource(
|
||||
externalSource,
|
||||
serviceIdentifier,
|
||||
normalizedRequest
|
||||
);
|
||||
}
|
||||
|
||||
async #verifyExternalSource(
|
||||
externalSource: NonNullable<ExternalSourceWithConnection>,
|
||||
serviceIdentifier: string,
|
||||
normalizedRequest: NormalizedRequest
|
||||
): Promise<VerifyExternalEventResponse> {
|
||||
switch (externalSource.type) {
|
||||
case "WEBHOOK": {
|
||||
return this.#verifyWebhook(
|
||||
externalSource,
|
||||
serviceIdentifier,
|
||||
normalizedRequest
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
status: "error",
|
||||
error: `Could not handle external source with unsupported type: ${externalSource.type}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #verifyWebhook(
|
||||
externalSource: NonNullable<ExternalSourceWithConnection>,
|
||||
serviceIdentifier: string,
|
||||
request: NormalizedRequest
|
||||
): Promise<VerifyExternalEventResponse> {
|
||||
switch (serviceIdentifier) {
|
||||
case "github": {
|
||||
return github.internalIntegration.webhooks!.verifyWebhookRequest({
|
||||
request,
|
||||
secret: externalSource.secret ?? undefined,
|
||||
});
|
||||
}
|
||||
case "whatsapp": {
|
||||
return whatsapp.internalIntegration.webhooks!.verifyWebhookRequest({
|
||||
request,
|
||||
secret: externalSource.secret ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (externalSource.manualRegistration) {
|
||||
return this.#handleManualWebhook(
|
||||
externalSource,
|
||||
serviceIdentifier,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ignored" as const,
|
||||
reason: `Could not handle external source with unsupported service: ${serviceIdentifier}`,
|
||||
};
|
||||
}
|
||||
|
||||
async #handleManualWebhook(
|
||||
externalSource: NonNullable<ExternalSourceWithConnection>,
|
||||
serviceIdentifier: string,
|
||||
request: NormalizedRequest
|
||||
): Promise<VerifyExternalEventResponse> {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,9 @@ import { WorkflowRunTriggerTimeout } from "./runs/runTriggerTimeout.server";
|
||||
import { DeliverScheduledEvent } from "./scheduler/deliverScheduledEvent.server";
|
||||
import { RegisterSchedulerSource } from "./scheduler/registerSchedulerSource.server";
|
||||
import { omit } from "~/utils/objects";
|
||||
import { findWorkflowStepById } from "~/models/workflowRunStep.server";
|
||||
import { InitializeRunOnce } from "./runOnce/initializeRunOnce.server";
|
||||
import { CompleteRunOnce } from "./runOnce/completeRunOnce.server";
|
||||
|
||||
let pulsarClient: PulsarClient;
|
||||
let triggerPublisher: ZodPublisher<TriggerCatalog>;
|
||||
@@ -271,6 +274,25 @@ function createCommandSubscriber() {
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
INITIALIZE_RUN_ONCE: async (id, data, properties) => {
|
||||
const service = new InitializeRunOnce();
|
||||
|
||||
await service.call(
|
||||
properties["x-workflow-run-id"],
|
||||
data.key,
|
||||
properties["x-timestamp"],
|
||||
data.runOnce
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
COMPLETE_RUN_ONCE: async (id, data, properties) => {
|
||||
const service = new CompleteRunOnce();
|
||||
|
||||
await service.call(data.runOnce);
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
@@ -434,6 +456,10 @@ const taskQueueCatalog = {
|
||||
data: CustomEventSchema.extend({ id: z.string() }),
|
||||
properties: z.object({}),
|
||||
},
|
||||
RESOLVE_RUN_ONCE: {
|
||||
data: z.object({ stepId: z.string(), hasRun: z.boolean() }),
|
||||
properties: z.object({}),
|
||||
},
|
||||
};
|
||||
|
||||
function createTaskQueue() {
|
||||
@@ -622,6 +648,37 @@ function createTaskQueue() {
|
||||
|
||||
return true;
|
||||
},
|
||||
RESOLVE_RUN_ONCE: async (id, data, properties) => {
|
||||
const step = await findWorkflowStepById(data.stepId);
|
||||
|
||||
if (!step) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = await commandResponsePublisher.publish(
|
||||
"RESOLVE_RUN_ONCE",
|
||||
{
|
||||
id: step.id,
|
||||
key: step.idempotencyKey,
|
||||
runOnce: {
|
||||
idempotencyKey: step.id,
|
||||
output: step.output
|
||||
? JSON.parse(JSON.stringify(step.output))
|
||||
: undefined,
|
||||
hasRun: data.hasRun,
|
||||
},
|
||||
},
|
||||
{
|
||||
"x-workflow-run-id": step.run.id,
|
||||
"x-api-key": step.run.environment.apiKey,
|
||||
"x-org-id": step.run.environment.organizationId,
|
||||
"x-workflow-id": step.run.workflowId,
|
||||
"x-env": step.run.environment.slug,
|
||||
}
|
||||
);
|
||||
|
||||
return !!response;
|
||||
},
|
||||
EXTERNAL_SOURCE_UPSERTED: async (id, data, properties) => {
|
||||
const service = new RegisterExternalSource();
|
||||
|
||||
@@ -688,6 +745,7 @@ function createTaskQueue() {
|
||||
"x-workflow-run-id": run.id,
|
||||
"x-ttl": run.workflow.triggerTtlInSeconds,
|
||||
"x-is-test": run.isTest ? "true" : "false",
|
||||
"x-app-origin": env.APP_ORIGIN,
|
||||
},
|
||||
{
|
||||
eventTimestamp: run.event.timestamp.getTime(),
|
||||
|
||||
@@ -4,15 +4,12 @@ import type {
|
||||
NormalizedResponse,
|
||||
PerformedRequestResponse,
|
||||
} from "@trigger.dev/integration-sdk";
|
||||
|
||||
import * as slack from "@trigger.dev/slack/internal";
|
||||
import * as resend from "@trigger.dev/resend/internal";
|
||||
import * as shopify from "@trigger.dev/shopify/internal";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import type { IntegrationRequest } from "~/models/integrationRequest.server";
|
||||
import { getAccessInfo } from "../accessInfo.server";
|
||||
import { RedisCacheService } from "../cacheService.server";
|
||||
import { getIntegrations } from "~/models/integrations.server";
|
||||
|
||||
type CallResponse =
|
||||
| {
|
||||
@@ -67,7 +64,8 @@ export class PerformIntegrationRequest {
|
||||
integrationRequest.externalService.connection.apiIdentifier,
|
||||
accessInfo,
|
||||
integrationRequest,
|
||||
cache
|
||||
cache,
|
||||
integrationRequest.externalService.workflowId
|
||||
);
|
||||
|
||||
if (performedRequest.ok) {
|
||||
@@ -225,39 +223,32 @@ export class PerformIntegrationRequest {
|
||||
service: string,
|
||||
accessInfo: AccessInfo,
|
||||
integrationRequest: IntegrationRequest,
|
||||
cache: CacheService
|
||||
cache: CacheService,
|
||||
workflowId: string
|
||||
): Promise<PerformedRequestResponse> {
|
||||
switch (service) {
|
||||
case "slack": {
|
||||
return slack.internalIntegration.requests!.perform({
|
||||
accessInfo,
|
||||
endpoint: integrationRequest.endpoint,
|
||||
params: integrationRequest.params,
|
||||
cache,
|
||||
metadata: { requestId: integrationRequest.id },
|
||||
});
|
||||
}
|
||||
case "shopify": {
|
||||
return shopify.internalIntegration.requests!.perform({
|
||||
accessInfo,
|
||||
endpoint: integrationRequest.endpoint,
|
||||
params: integrationRequest.params,
|
||||
cache,
|
||||
metadata: { requestId: integrationRequest.id },
|
||||
});
|
||||
}
|
||||
case "resend": {
|
||||
return resend.internalIntegration.requests!.perform({
|
||||
accessInfo,
|
||||
endpoint: integrationRequest.endpoint,
|
||||
params: integrationRequest.params,
|
||||
cache,
|
||||
metadata: { requestId: integrationRequest.id },
|
||||
});
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown service: ${service}`);
|
||||
}
|
||||
const integrationInfo = getIntegrations(true).find(
|
||||
(i) => i.metadata.slug === service
|
||||
);
|
||||
|
||||
if (!integrationInfo) {
|
||||
throw new Error(`Unknown service: ${service}`);
|
||||
}
|
||||
|
||||
const { requests } = integrationInfo;
|
||||
|
||||
if (!requests) {
|
||||
throw new Error(`Service ${service} does not support requests`);
|
||||
}
|
||||
|
||||
return requests.perform({
|
||||
accessInfo,
|
||||
endpoint: integrationRequest.endpoint,
|
||||
params: integrationRequest.params,
|
||||
cache,
|
||||
metadata: {
|
||||
requestId: integrationRequest.id,
|
||||
workflowId: workflowId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CompleteRunOnceSchema } from "@trigger.dev/common-schemas";
|
||||
import type { z } from "zod";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
type RunOnce = z.infer<typeof CompleteRunOnceSchema>;
|
||||
|
||||
export class CompleteRunOnce {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async call(runOnce: RunOnce) {
|
||||
return this.#prismaClient.workflowRunStep.updateMany({
|
||||
where: {
|
||||
id: runOnce.idempotencyKey,
|
||||
status: "RUNNING",
|
||||
},
|
||||
data: {
|
||||
status: "SUCCESS",
|
||||
finishedAt: new Date(),
|
||||
output:
|
||||
runOnce.type === "REMOTE" && typeof runOnce.output === "string"
|
||||
? safeOutputParse(runOnce.output)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function safeOutputParse(output?: string) {
|
||||
if (typeof output !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { InitializeRunOnceSchema } from "@trigger.dev/common-schemas";
|
||||
import type { z } from "zod";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import type { WorkflowRunStep } from "~/models/workflowRun.server";
|
||||
import { createStepOnce } from "~/models/workflowRunStep.server";
|
||||
import { taskQueue } from "../messageBroker.server";
|
||||
|
||||
type RunOnce = z.infer<typeof InitializeRunOnceSchema>;
|
||||
|
||||
export class InitializeRunOnce {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async call(runId: string, key: string, timestamp: string, runOnce: RunOnce) {
|
||||
const idempotentStep = await createStepOnce(runId, key, {
|
||||
type: "RUN_ONCE",
|
||||
status: "RUNNING",
|
||||
startedAt: new Date(),
|
||||
context: {},
|
||||
ts: timestamp,
|
||||
});
|
||||
|
||||
if (idempotentStep.status === "EXISTING") {
|
||||
return this.#handleExistingStep(idempotentStep.step);
|
||||
}
|
||||
|
||||
const workflowStep = idempotentStep.step;
|
||||
|
||||
return this.#handleNewStep(workflowStep, runOnce);
|
||||
}
|
||||
|
||||
async #handleExistingStep(step: WorkflowRunStep) {
|
||||
return taskQueue.publish(
|
||||
"RESOLVE_RUN_ONCE",
|
||||
{
|
||||
stepId: step.id,
|
||||
hasRun: true,
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
async #handleNewStep(step: WorkflowRunStep, runOnce: RunOnce) {
|
||||
if (runOnce.type === "LOCAL_ONLY") {
|
||||
await this.#prismaClient.workflowRunStep.update({
|
||||
where: {
|
||||
id: step.id,
|
||||
},
|
||||
data: {
|
||||
status: "SUCCESS",
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return taskQueue.publish(
|
||||
"RESOLVE_RUN_ONCE",
|
||||
{
|
||||
stepId: step.id,
|
||||
hasRun: false,
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user