node-red-contrib-letmepost 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +121 -0
- package/letmepost/letmepost-config.html +49 -0
- package/letmepost/letmepost-config.js +19 -0
- package/letmepost/letmepost-get-post.html +52 -0
- package/letmepost/letmepost-get-post.js +42 -0
- package/letmepost/letmepost-list-accounts.html +53 -0
- package/letmepost/letmepost-list-accounts.js +43 -0
- package/letmepost/letmepost-publish.html +115 -0
- package/letmepost/letmepost-publish.js +111 -0
- package/letmepost/letmepost-webhook.html +91 -0
- package/letmepost/letmepost-webhook.js +147 -0
- package/letmepost/lib/client.js +157 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright 2026 letmepost.dev
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# node-red-contrib-letmepost
|
|
2
|
+
|
|
3
|
+
Node-RED nodes for [letmepost.dev](https://letmepost.dev) — the open-source
|
|
4
|
+
social-media publishing API. Publish or schedule to Bluesky, X/Twitter,
|
|
5
|
+
LinkedIn, Instagram, Threads, Facebook, Pinterest and TikTok through one API,
|
|
6
|
+
and trigger flows from signed webhook deliveries.
|
|
7
|
+
|
|
8
|
+
## Nodes
|
|
9
|
+
|
|
10
|
+
| Node | What it does |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `letmepost-config` | Config node holding your API key and base URL. |
|
|
13
|
+
| `letmepost-publish` | Publish or schedule a post (`POST /v1/posts`). |
|
|
14
|
+
| `letmepost-get-post` | Fetch a post and its attempts (`GET /v1/posts/{id}`). |
|
|
15
|
+
| `letmepost-list-accounts` | List connected accounts (`GET /v1/accounts`). |
|
|
16
|
+
| `letmepost-webhook` | Trigger a flow on a signature-verified webhook event. |
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
From your Node-RED user directory (`~/.node-red`):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install node-red-contrib-letmepost
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or in the editor: **Menu → Manage palette → Install** and search for
|
|
27
|
+
`node-red-contrib-letmepost`. Restart Node-RED.
|
|
28
|
+
|
|
29
|
+
Requires Node.js 18+ (uses the global `fetch`) and Node-RED 3.0+.
|
|
30
|
+
|
|
31
|
+
## Configure credentials
|
|
32
|
+
|
|
33
|
+
1. Mint an API key in the letmepost dashboard under **Settings → API Keys**
|
|
34
|
+
(`lmp_live_…` for production, `lmp_test_…` for the test environment).
|
|
35
|
+
2. Drop any letmepost node onto a flow, open it, and next to **Connection**
|
|
36
|
+
click the pencil to add a new `letmepost-config`.
|
|
37
|
+
3. Paste the key into **API Key**. Leave **Base URL** as
|
|
38
|
+
`https://api.letmepost.dev` unless you self-host.
|
|
39
|
+
|
|
40
|
+
The key is stored encrypted in Node-RED's credentials store, never in the
|
|
41
|
+
flow JSON.
|
|
42
|
+
|
|
43
|
+
## Nodes in detail
|
|
44
|
+
|
|
45
|
+
### letmepost-publish
|
|
46
|
+
|
|
47
|
+
Publishes or schedules a post. Each node field can be overridden by the matching
|
|
48
|
+
`msg` property (`msg` wins; otherwise the node config is used).
|
|
49
|
+
|
|
50
|
+
- **Account IDs** — comma-separated IDs, or an array on `msg.accountIds`. Each
|
|
51
|
+
becomes a target `{ accountId }`.
|
|
52
|
+
- **Platform** — used only when no account IDs are given; auto-resolves to the
|
|
53
|
+
single connected account for that platform (e.g. `bluesky`).
|
|
54
|
+
- **Text** — applied to every target.
|
|
55
|
+
- **First Comment** — auto-posted reply where the platform supports it.
|
|
56
|
+
- **Schedule At** — ISO-8601 timestamp in the future; queues the batch (HTTP
|
|
57
|
+
202, status `queued`).
|
|
58
|
+
- **Profile ID**, **Idempotency Key** — optional. The idempotency key is sent as
|
|
59
|
+
the `Idempotency-Key` header so retries never double-post.
|
|
60
|
+
- `msg.media` — optional array of MediaInput objects, passed through verbatim
|
|
61
|
+
(e.g. `[{ "kind": "image", "mediaId": "med_…" }]`).
|
|
62
|
+
- `msg.payload.targets` — advanced: if `msg.payload` already contains a
|
|
63
|
+
`targets` array, the whole payload is sent as the request body and the fields
|
|
64
|
+
above are ignored. Use this to build arbitrary multi-target / per-target
|
|
65
|
+
override shapes.
|
|
66
|
+
|
|
67
|
+
Output `msg.payload` is the `CreatePostResponse` envelope:
|
|
68
|
+
`{ id, status, createdAt, results[] }`. The node status reflects the batch
|
|
69
|
+
status (`published` / `partial_failed` / `failed` / `queued`).
|
|
70
|
+
|
|
71
|
+
### letmepost-get-post
|
|
72
|
+
|
|
73
|
+
`GET /v1/posts/{id}`. Set **Post ID** on the node or `msg.postId`. Output
|
|
74
|
+
`msg.payload` is the `PostDetail` (the post row plus `attempts[]`).
|
|
75
|
+
|
|
76
|
+
### letmepost-list-accounts
|
|
77
|
+
|
|
78
|
+
`GET /v1/accounts`. Optional **Profile ID** filter (or `msg.profileId`). Output
|
|
79
|
+
`msg.payload` is the array of `Account` objects. Feed an account's `id` into the
|
|
80
|
+
publish node's Account IDs.
|
|
81
|
+
|
|
82
|
+
### letmepost-webhook
|
|
83
|
+
|
|
84
|
+
Triggers a flow when letmepost delivers an event. It registers an HTTP `POST`
|
|
85
|
+
route at the configured **URL Path**, verifies the signature, and emits the
|
|
86
|
+
event only when valid.
|
|
87
|
+
|
|
88
|
+
This node does **not** register itself with the API. Webhook endpoints are
|
|
89
|
+
created from the letmepost dashboard — the registration API is a
|
|
90
|
+
dashboard-session operation and does not accept an `lmp_live_` API key. Register
|
|
91
|
+
the URL by hand:
|
|
92
|
+
|
|
93
|
+
**Setup**
|
|
94
|
+
|
|
95
|
+
1. Pick a **URL Path** (default `/letmepost-webhook`). Your public URL is the
|
|
96
|
+
Node-RED base URL plus this path,
|
|
97
|
+
e.g. `https://your-host:1880/letmepost-webhook`. Node-RED has to be reachable
|
|
98
|
+
from the public internet for letmepost to deliver to it.
|
|
99
|
+
2. In the letmepost dashboard, go to **Settings → Webhooks**, add an endpoint,
|
|
100
|
+
and paste that public URL. Choose the event types you want
|
|
101
|
+
(`post.published`, `post.failed`, `token.expiring`, …) or subscribe to all.
|
|
102
|
+
3. Copy the `signingSecret` (`whsec_…`) the dashboard shows once at creation
|
|
103
|
+
into the node's **Signing Secret** field.
|
|
104
|
+
|
|
105
|
+
**Verification.** Each delivery carries
|
|
106
|
+
`X-Letmepost-Signature: sha256=<hex>`, an HMAC-SHA256 of the raw request body
|
|
107
|
+
keyed by the signing secret (no timestamp). The node recomputes it over the
|
|
108
|
+
exact received bytes and compares in constant time. Deliveries that fail get a
|
|
109
|
+
`401` and are dropped; malformed JSON gets a `400`.
|
|
110
|
+
|
|
111
|
+
Output: `msg.payload` is the parsed event, `msg.topic` is the event `type`.
|
|
112
|
+
|
|
113
|
+
## Errors
|
|
114
|
+
|
|
115
|
+
Action nodes report API failures via `node.error(err, msg)`, so a **Catch** node
|
|
116
|
+
downstream can handle them. The message surfaces the letmepost error code, the
|
|
117
|
+
failed `rule`, the `remediation`, and the `requestId`.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
[Apache-2.0](./LICENSE)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('letmepost-config', {
|
|
3
|
+
category: 'config',
|
|
4
|
+
defaults: {
|
|
5
|
+
name: { value: '' },
|
|
6
|
+
baseUrl: { value: 'https://api.letmepost.dev', required: true },
|
|
7
|
+
},
|
|
8
|
+
credentials: {
|
|
9
|
+
apiKey: { type: 'password' },
|
|
10
|
+
},
|
|
11
|
+
label: function () {
|
|
12
|
+
return this.name || 'letmepost';
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<script type="text/html" data-template-name="letmepost-config">
|
|
18
|
+
<div class="form-row">
|
|
19
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
20
|
+
<input type="text" id="node-config-input-name" placeholder="letmepost" />
|
|
21
|
+
</div>
|
|
22
|
+
<div class="form-row">
|
|
23
|
+
<label for="node-config-input-apiKey"><i class="fa fa-key"></i> API Key</label>
|
|
24
|
+
<input type="password" id="node-config-input-apiKey" placeholder="lmp_live_…" />
|
|
25
|
+
</div>
|
|
26
|
+
<div class="form-row">
|
|
27
|
+
<label for="node-config-input-baseUrl"><i class="fa fa-globe"></i> Base URL</label>
|
|
28
|
+
<input type="text" id="node-config-input-baseUrl" placeholder="https://api.letmepost.dev" />
|
|
29
|
+
</div>
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<script type="text/html" data-help-name="letmepost-config">
|
|
33
|
+
<p>Holds the credentials shared by every letmepost node.</p>
|
|
34
|
+
<h3>Fields</h3>
|
|
35
|
+
<dl class="message-properties">
|
|
36
|
+
<dt>API Key <span class="property-type">password</span></dt>
|
|
37
|
+
<dd>
|
|
38
|
+
Your letmepost.dev API key. Create one in the dashboard under
|
|
39
|
+
Settings → API Keys. Use an <code>lmp_live_</code> key for
|
|
40
|
+
production or <code>lmp_test_</code> for the test environment. Stored
|
|
41
|
+
encrypted in Node-RED's credentials store, never in the flow file.
|
|
42
|
+
</dd>
|
|
43
|
+
<dt>Base URL <span class="property-type">string</span></dt>
|
|
44
|
+
<dd>
|
|
45
|
+
The API base URL. Defaults to <code>https://api.letmepost.dev</code>.
|
|
46
|
+
Change only if you run a self-hosted instance.
|
|
47
|
+
</dd>
|
|
48
|
+
</dl>
|
|
49
|
+
</script>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { DEFAULT_BASE_URL } = require('./lib/client');
|
|
4
|
+
|
|
5
|
+
module.exports = function (RED) {
|
|
6
|
+
function LetmepostConfigNode(config) {
|
|
7
|
+
RED.nodes.createNode(this, config);
|
|
8
|
+
this.name = config.name;
|
|
9
|
+
this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).trim();
|
|
10
|
+
// this.credentials.apiKey is populated by Node-RED from the encrypted
|
|
11
|
+
// credentials store declared below.
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
RED.nodes.registerType('letmepost-config', LetmepostConfigNode, {
|
|
15
|
+
credentials: {
|
|
16
|
+
apiKey: { type: 'password' },
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('letmepost-get-post', {
|
|
3
|
+
category: 'letmepost',
|
|
4
|
+
color: '#1f6feb',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
letmepostConfig: { value: '', type: 'letmepost-config', required: true },
|
|
8
|
+
postId: { value: '' },
|
|
9
|
+
},
|
|
10
|
+
inputs: 1,
|
|
11
|
+
outputs: 1,
|
|
12
|
+
icon: 'font-awesome/fa-search',
|
|
13
|
+
label: function () {
|
|
14
|
+
return this.name || 'letmepost get post';
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<script type="text/html" data-template-name="letmepost-get-post">
|
|
20
|
+
<div class="form-row">
|
|
21
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
22
|
+
<input type="text" id="node-input-name" placeholder="letmepost get post" />
|
|
23
|
+
</div>
|
|
24
|
+
<div class="form-row">
|
|
25
|
+
<label for="node-input-letmepostConfig"><i class="fa fa-key"></i> Connection</label>
|
|
26
|
+
<input type="text" id="node-input-letmepostConfig" />
|
|
27
|
+
</div>
|
|
28
|
+
<div class="form-row">
|
|
29
|
+
<label for="node-input-postId"><i class="fa fa-hashtag"></i> Post ID</label>
|
|
30
|
+
<input type="text" id="node-input-postId" placeholder="post ID (or set msg.postId)" />
|
|
31
|
+
</div>
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<script type="text/html" data-help-name="letmepost-get-post">
|
|
35
|
+
<p>Fetches a single post and its publish attempts via
|
|
36
|
+
<code>GET /v1/posts/{id}</code>.</p>
|
|
37
|
+
|
|
38
|
+
<h3>Inputs</h3>
|
|
39
|
+
<dl class="message-properties">
|
|
40
|
+
<dt class="optional">postId <span class="property-type">string</span></dt>
|
|
41
|
+
<dd>The post ID to retrieve. Overrides the node's Post ID field. Use the
|
|
42
|
+
<code>postId</code> returned inside a publish node's
|
|
43
|
+
<code>results[]</code>.</dd>
|
|
44
|
+
</dl>
|
|
45
|
+
|
|
46
|
+
<h3>Output</h3>
|
|
47
|
+
<dl class="message-properties">
|
|
48
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
49
|
+
<dd>The <code>PostDetail</code> object: the post row plus an
|
|
50
|
+
<code>attempts[]</code> array.</dd>
|
|
51
|
+
</dl>
|
|
52
|
+
</script>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { letmepostRequest } = require('./lib/client');
|
|
4
|
+
|
|
5
|
+
module.exports = function (RED) {
|
|
6
|
+
function LetmepostGetPostNode(config) {
|
|
7
|
+
RED.nodes.createNode(this, config);
|
|
8
|
+
const node = this;
|
|
9
|
+
node.letmepostConfig = RED.nodes.getNode(config.letmepostConfig);
|
|
10
|
+
|
|
11
|
+
node.on('input', async function (msg, send, done) {
|
|
12
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
13
|
+
done = done || function (err) { if (err) node.error(err, msg); };
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const postId =
|
|
17
|
+
msg.postId !== undefined && msg.postId !== null && msg.postId !== ''
|
|
18
|
+
? msg.postId
|
|
19
|
+
: config.postId;
|
|
20
|
+
if (!postId) {
|
|
21
|
+
throw new Error('No post ID. Set a Post ID on the node or provide msg.postId.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
node.status({ fill: 'blue', shape: 'dot', text: 'fetching' });
|
|
25
|
+
const response = await letmepostRequest(node.letmepostConfig, {
|
|
26
|
+
method: 'GET',
|
|
27
|
+
endpoint: `/v1/posts/${encodeURIComponent(String(postId))}`,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
node.status({ fill: 'green', shape: 'dot', text: response.status || 'ok' });
|
|
31
|
+
msg.payload = response;
|
|
32
|
+
send(msg);
|
|
33
|
+
done();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
36
|
+
done(err);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
RED.nodes.registerType('letmepost-get-post', LetmepostGetPostNode);
|
|
42
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('letmepost-list-accounts', {
|
|
3
|
+
category: 'letmepost',
|
|
4
|
+
color: '#1f6feb',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
letmepostConfig: { value: '', type: 'letmepost-config', required: true },
|
|
8
|
+
profileId: { value: '' },
|
|
9
|
+
},
|
|
10
|
+
inputs: 1,
|
|
11
|
+
outputs: 1,
|
|
12
|
+
icon: 'font-awesome/fa-users',
|
|
13
|
+
label: function () {
|
|
14
|
+
return this.name || 'letmepost list accounts';
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<script type="text/html" data-template-name="letmepost-list-accounts">
|
|
20
|
+
<div class="form-row">
|
|
21
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
22
|
+
<input type="text" id="node-input-name" placeholder="letmepost list accounts" />
|
|
23
|
+
</div>
|
|
24
|
+
<div class="form-row">
|
|
25
|
+
<label for="node-input-letmepostConfig"><i class="fa fa-key"></i> Connection</label>
|
|
26
|
+
<input type="text" id="node-input-letmepostConfig" />
|
|
27
|
+
</div>
|
|
28
|
+
<div class="form-row">
|
|
29
|
+
<label for="node-input-profileId"><i class="fa fa-id-card-o"></i> Profile ID</label>
|
|
30
|
+
<input type="text" id="node-input-profileId" placeholder="optional profile UUID" />
|
|
31
|
+
</div>
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<script type="text/html" data-help-name="letmepost-list-accounts">
|
|
35
|
+
<p>Lists the social accounts connected to your organization via
|
|
36
|
+
<code>GET /v1/accounts</code>.</p>
|
|
37
|
+
|
|
38
|
+
<h3>Inputs</h3>
|
|
39
|
+
<dl class="message-properties">
|
|
40
|
+
<dt class="optional">profileId <span class="property-type">string</span></dt>
|
|
41
|
+
<dd>Only return accounts under this profile. Overrides the node field.</dd>
|
|
42
|
+
</dl>
|
|
43
|
+
|
|
44
|
+
<h3>Output</h3>
|
|
45
|
+
<dl class="message-properties">
|
|
46
|
+
<dt>payload <span class="property-type">array</span></dt>
|
|
47
|
+
<dd>An array of <code>Account</code> objects (the API response's
|
|
48
|
+
<code>data</code> array). Each carries <code>id</code>,
|
|
49
|
+
<code>platform</code>, <code>platformAccountId</code> and
|
|
50
|
+
<code>displayName</code>. Feed an account's <code>id</code> into the
|
|
51
|
+
publish node's Account IDs.</dd>
|
|
52
|
+
</dl>
|
|
53
|
+
</script>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { letmepostRequest } = require('./lib/client');
|
|
4
|
+
|
|
5
|
+
module.exports = function (RED) {
|
|
6
|
+
function LetmepostListAccountsNode(config) {
|
|
7
|
+
RED.nodes.createNode(this, config);
|
|
8
|
+
const node = this;
|
|
9
|
+
node.letmepostConfig = RED.nodes.getNode(config.letmepostConfig);
|
|
10
|
+
|
|
11
|
+
node.on('input', async function (msg, send, done) {
|
|
12
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
13
|
+
done = done || function (err) { if (err) node.error(err, msg); };
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const query = {};
|
|
17
|
+
const profileId =
|
|
18
|
+
msg.profileId !== undefined && msg.profileId !== null && msg.profileId !== ''
|
|
19
|
+
? msg.profileId
|
|
20
|
+
: config.profileId;
|
|
21
|
+
if (profileId) query.profileId = String(profileId);
|
|
22
|
+
|
|
23
|
+
node.status({ fill: 'blue', shape: 'dot', text: 'fetching' });
|
|
24
|
+
const response = await letmepostRequest(node.letmepostConfig, {
|
|
25
|
+
method: 'GET',
|
|
26
|
+
endpoint: '/v1/accounts',
|
|
27
|
+
query,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const accounts = Array.isArray(response.data) ? response.data : [];
|
|
31
|
+
node.status({ fill: 'green', shape: 'dot', text: `${accounts.length} accounts` });
|
|
32
|
+
msg.payload = accounts;
|
|
33
|
+
send(msg);
|
|
34
|
+
done();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
37
|
+
done(err);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
RED.nodes.registerType('letmepost-list-accounts', LetmepostListAccountsNode);
|
|
43
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('letmepost-publish', {
|
|
3
|
+
category: 'letmepost',
|
|
4
|
+
color: '#1f6feb',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
letmepostConfig: { value: '', type: 'letmepost-config', required: true },
|
|
8
|
+
accountIds: { value: '' },
|
|
9
|
+
platform: { value: '' },
|
|
10
|
+
text: { value: '' },
|
|
11
|
+
firstComment: { value: '' },
|
|
12
|
+
scheduledAt: { value: '' },
|
|
13
|
+
profileId: { value: '' },
|
|
14
|
+
idempotencyKey: { value: '' },
|
|
15
|
+
},
|
|
16
|
+
inputs: 1,
|
|
17
|
+
outputs: 1,
|
|
18
|
+
icon: 'font-awesome/fa-paper-plane',
|
|
19
|
+
label: function () {
|
|
20
|
+
return this.name || 'letmepost publish';
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<script type="text/html" data-template-name="letmepost-publish">
|
|
26
|
+
<div class="form-row">
|
|
27
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
28
|
+
<input type="text" id="node-input-name" placeholder="letmepost publish" />
|
|
29
|
+
</div>
|
|
30
|
+
<div class="form-row">
|
|
31
|
+
<label for="node-input-letmepostConfig"><i class="fa fa-key"></i> Connection</label>
|
|
32
|
+
<input type="text" id="node-input-letmepostConfig" />
|
|
33
|
+
</div>
|
|
34
|
+
<div class="form-row">
|
|
35
|
+
<label for="node-input-accountIds"><i class="fa fa-users"></i> Account IDs</label>
|
|
36
|
+
<input type="text" id="node-input-accountIds" placeholder="comma-separated account IDs" />
|
|
37
|
+
</div>
|
|
38
|
+
<div class="form-row">
|
|
39
|
+
<label for="node-input-platform"><i class="fa fa-share-alt"></i> Platform</label>
|
|
40
|
+
<input type="text" id="node-input-platform" placeholder="e.g. bluesky (used only when no Account IDs)" />
|
|
41
|
+
</div>
|
|
42
|
+
<div class="form-row">
|
|
43
|
+
<label for="node-input-text"><i class="fa fa-pencil"></i> Text</label>
|
|
44
|
+
<input type="text" id="node-input-text" placeholder="The post text" />
|
|
45
|
+
</div>
|
|
46
|
+
<div class="form-row">
|
|
47
|
+
<label for="node-input-firstComment"><i class="fa fa-comment"></i> First Comment</label>
|
|
48
|
+
<input type="text" id="node-input-firstComment" placeholder="optional first comment" />
|
|
49
|
+
</div>
|
|
50
|
+
<div class="form-row">
|
|
51
|
+
<label for="node-input-scheduledAt"><i class="fa fa-clock-o"></i> Schedule At</label>
|
|
52
|
+
<input type="text" id="node-input-scheduledAt" placeholder="ISO-8601, e.g. 2026-06-01T12:00:00.000Z" />
|
|
53
|
+
</div>
|
|
54
|
+
<div class="form-row">
|
|
55
|
+
<label for="node-input-profileId"><i class="fa fa-id-card-o"></i> Profile ID</label>
|
|
56
|
+
<input type="text" id="node-input-profileId" placeholder="optional profile UUID" />
|
|
57
|
+
</div>
|
|
58
|
+
<div class="form-row">
|
|
59
|
+
<label for="node-input-idempotencyKey"><i class="fa fa-shield"></i> Idempotency Key</label>
|
|
60
|
+
<input type="text" id="node-input-idempotencyKey" placeholder="optional, retries never double-post" />
|
|
61
|
+
</div>
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<script type="text/html" data-help-name="letmepost-publish">
|
|
65
|
+
<p>Publishes or schedules a post to one or more connected accounts via
|
|
66
|
+
<code>POST /v1/posts</code>.</p>
|
|
67
|
+
|
|
68
|
+
<h3>Inputs</h3>
|
|
69
|
+
<p>Every config field can be overridden by the matching <code>msg</code>
|
|
70
|
+
property. The fallback order is <b>msg property → node config</b>.</p>
|
|
71
|
+
<dl class="message-properties">
|
|
72
|
+
<dt class="optional">accountIds <span class="property-type">string | array</span></dt>
|
|
73
|
+
<dd>One or more connected account IDs. A comma-separated string or an
|
|
74
|
+
array. Each becomes a target <code>{ accountId }</code>.</dd>
|
|
75
|
+
<dt class="optional">platform <span class="property-type">string</span></dt>
|
|
76
|
+
<dd>Used only when no account IDs are given: auto-resolves to the single
|
|
77
|
+
connected account for that platform (e.g. <code>bluesky</code>,
|
|
78
|
+
<code>twitter</code>, <code>linkedin</code>).</dd>
|
|
79
|
+
<dt class="optional">text <span class="property-type">string</span></dt>
|
|
80
|
+
<dd>The post text, applied to every target that has no per-target text.</dd>
|
|
81
|
+
<dt class="optional">firstComment <span class="property-type">string</span></dt>
|
|
82
|
+
<dd>Auto-posted reply after publishing, where the platform supports it.</dd>
|
|
83
|
+
<dt class="optional">scheduledAt <span class="property-type">string</span></dt>
|
|
84
|
+
<dd>ISO-8601 timestamp at least 1 second in the future. When set, the batch
|
|
85
|
+
is queued and the response is <code>202</code> with status
|
|
86
|
+
<code>queued</code>.</dd>
|
|
87
|
+
<dt class="optional">profileId <span class="property-type">string</span></dt>
|
|
88
|
+
<dd>Profile scope for this batch.</dd>
|
|
89
|
+
<dt class="optional">idempotencyKey <span class="property-type">string</span></dt>
|
|
90
|
+
<dd>Sent as the <code>Idempotency-Key</code> header. Replays within 24
|
|
91
|
+
hours return the original response.</dd>
|
|
92
|
+
<dt class="optional">media <span class="property-type">array</span></dt>
|
|
93
|
+
<dd>Optional array of MediaInput objects (e.g.
|
|
94
|
+
<code>[{ kind: "image", mediaId: "med_…" }]</code>) passed through verbatim.</dd>
|
|
95
|
+
<dt class="optional">payload.targets <span class="property-type">array</span></dt>
|
|
96
|
+
<dd>Advanced: if <code>msg.payload</code> already contains a
|
|
97
|
+
<code>targets</code> array, the whole payload is sent as the request body
|
|
98
|
+
and the fields above are ignored. Use this to build any multi-target or
|
|
99
|
+
per-target-override shape the API accepts.</dd>
|
|
100
|
+
</dl>
|
|
101
|
+
|
|
102
|
+
<h3>Output</h3>
|
|
103
|
+
<dl class="message-properties">
|
|
104
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
105
|
+
<dd>The <code>CreatePostResponse</code> batch envelope:
|
|
106
|
+
<code>{ id, status, createdAt, results[] }</code>. The node status shows
|
|
107
|
+
the batch <code>status</code> (published / partial_failed / failed /
|
|
108
|
+
queued).</dd>
|
|
109
|
+
</dl>
|
|
110
|
+
|
|
111
|
+
<h3>Errors</h3>
|
|
112
|
+
<p>API failures are reported via <code>node.error(err, msg)</code> so a Catch
|
|
113
|
+
node downstream can handle them. The message includes the letmepost error
|
|
114
|
+
code, <code>rule</code>, remediation and request ID.</p>
|
|
115
|
+
</script>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { letmepostRequest } = require('./lib/client');
|
|
4
|
+
|
|
5
|
+
module.exports = function (RED) {
|
|
6
|
+
function LetmepostPublishNode(config) {
|
|
7
|
+
RED.nodes.createNode(this, config);
|
|
8
|
+
const node = this;
|
|
9
|
+
node.letmepostConfig = RED.nodes.getNode(config.letmepostConfig);
|
|
10
|
+
|
|
11
|
+
node.on('input', async function (msg, send, done) {
|
|
12
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
13
|
+
done = done || function (err) { if (err) node.error(err, msg); };
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const body = buildBody(config, msg);
|
|
17
|
+
const requestOptions = { method: 'POST', endpoint: '/v1/posts', body };
|
|
18
|
+
|
|
19
|
+
const idempotencyKey = pick(msg.idempotencyKey, config.idempotencyKey);
|
|
20
|
+
if (idempotencyKey) {
|
|
21
|
+
requestOptions.headers = { 'Idempotency-Key': String(idempotencyKey) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
node.status({ fill: 'blue', shape: 'dot', text: 'publishing' });
|
|
25
|
+
const response = await letmepostRequest(node.letmepostConfig, requestOptions);
|
|
26
|
+
|
|
27
|
+
const status = response && response.status ? response.status : 'done';
|
|
28
|
+
const shape = status === 'published' || status === 'queued' ? 'dot' : 'ring';
|
|
29
|
+
const fill = status === 'failed' ? 'red' : status === 'partial_failed' ? 'yellow' : 'green';
|
|
30
|
+
node.status({ fill, shape, text: status });
|
|
31
|
+
|
|
32
|
+
msg.payload = response;
|
|
33
|
+
send(msg);
|
|
34
|
+
done();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
37
|
+
done(err);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
RED.nodes.registerType('letmepost-publish', LetmepostPublishNode);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Resolve a value from msg first, falling back to the node config. Empty strings
|
|
46
|
+
// and empty arrays count as "not set" so a configured default still wins.
|
|
47
|
+
function pick(fromMsg, fromConfig) {
|
|
48
|
+
if (fromMsg !== undefined && fromMsg !== null && fromMsg !== '') return fromMsg;
|
|
49
|
+
return fromConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseAccountIds(value) {
|
|
53
|
+
if (Array.isArray(value)) return value.filter(Boolean).map(String);
|
|
54
|
+
if (typeof value === 'string') {
|
|
55
|
+
return value
|
|
56
|
+
.split(',')
|
|
57
|
+
.map((s) => s.trim())
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildBody(config, msg) {
|
|
64
|
+
// A fully formed body on msg.payload.targets wins outright — lets advanced
|
|
65
|
+
// users build any multi-target / per-target-override shape the API accepts.
|
|
66
|
+
if (msg.payload && typeof msg.payload === 'object' && Array.isArray(msg.payload.targets)) {
|
|
67
|
+
return msg.payload;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const accountIds = parseAccountIds(pick(msg.accountIds, config.accountIds));
|
|
71
|
+
const platform = pick(msg.platform, config.platform);
|
|
72
|
+
|
|
73
|
+
let targets;
|
|
74
|
+
if (accountIds.length > 0) {
|
|
75
|
+
targets = accountIds.map((id) => ({ accountId: id }));
|
|
76
|
+
} else if (platform) {
|
|
77
|
+
// Auto-resolve hint: the org must have exactly one connected account for
|
|
78
|
+
// this platform (see CreatePostRequest in the OpenAPI spec).
|
|
79
|
+
targets = [{ platform: String(platform) }];
|
|
80
|
+
} else {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'No targets. Set Account IDs or a Platform on the node, or provide msg.payload.targets.',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const body = { targets };
|
|
87
|
+
|
|
88
|
+
const text = pick(msg.text, config.text);
|
|
89
|
+
if (text) body.text = String(text);
|
|
90
|
+
|
|
91
|
+
const firstComment = pick(msg.firstComment, config.firstComment);
|
|
92
|
+
if (firstComment) body.firstComment = { text: String(firstComment) };
|
|
93
|
+
|
|
94
|
+
const profileId = pick(msg.profileId, config.profileId);
|
|
95
|
+
if (profileId) body.profileId = String(profileId);
|
|
96
|
+
|
|
97
|
+
const scheduledAt = pick(msg.scheduledAt, config.scheduledAt);
|
|
98
|
+
if (scheduledAt) {
|
|
99
|
+
body.scheduledAt = String(scheduledAt);
|
|
100
|
+
body.publishNow = false;
|
|
101
|
+
} else {
|
|
102
|
+
body.publishNow = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Optional media passthrough: accept a ready-made MediaInput[] on msg.media.
|
|
106
|
+
if (Array.isArray(msg.media) && msg.media.length > 0) {
|
|
107
|
+
body.media = msg.media;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return body;
|
|
111
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('letmepost-webhook', {
|
|
3
|
+
category: 'letmepost',
|
|
4
|
+
color: '#1f6feb',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
letmepostConfig: { value: '', type: 'letmepost-config', required: false },
|
|
8
|
+
path: { value: '/letmepost-webhook', required: true },
|
|
9
|
+
},
|
|
10
|
+
credentials: {
|
|
11
|
+
signingSecret: { type: 'password' },
|
|
12
|
+
},
|
|
13
|
+
inputs: 0,
|
|
14
|
+
outputs: 1,
|
|
15
|
+
icon: 'font-awesome/fa-bolt',
|
|
16
|
+
label: function () {
|
|
17
|
+
return this.name || 'letmepost webhook';
|
|
18
|
+
},
|
|
19
|
+
oneditprepare: function () {
|
|
20
|
+
// nothing dynamic; placeholder for future use
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<script type="text/html" data-template-name="letmepost-webhook">
|
|
26
|
+
<div class="form-row">
|
|
27
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
28
|
+
<input type="text" id="node-input-name" placeholder="letmepost webhook" />
|
|
29
|
+
</div>
|
|
30
|
+
<div class="form-row">
|
|
31
|
+
<label for="node-input-letmepostConfig"><i class="fa fa-key"></i> Connection</label>
|
|
32
|
+
<input type="text" id="node-input-letmepostConfig" />
|
|
33
|
+
</div>
|
|
34
|
+
<div class="form-row">
|
|
35
|
+
<label for="node-input-path"><i class="fa fa-globe"></i> URL Path</label>
|
|
36
|
+
<input type="text" id="node-input-path" placeholder="/letmepost-webhook" />
|
|
37
|
+
</div>
|
|
38
|
+
<div class="form-row">
|
|
39
|
+
<label for="node-input-signingSecret"><i class="fa fa-shield"></i> Signing Secret</label>
|
|
40
|
+
<input type="password" id="node-input-signingSecret" placeholder="whsec_…" />
|
|
41
|
+
</div>
|
|
42
|
+
<div class="form-tips">
|
|
43
|
+
The full public URL is your Node-RED host + this path, e.g.
|
|
44
|
+
<code>https://your-host:1880/letmepost-webhook</code>. Register that URL
|
|
45
|
+
in the letmepost <b>dashboard</b> (Settings → Webhooks), then paste
|
|
46
|
+
the <code>signingSecret</code> it shows once into the field above.
|
|
47
|
+
</div>
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<script type="text/html" data-help-name="letmepost-webhook">
|
|
51
|
+
<p>Starts a flow when letmepost.dev delivers a webhook event. The node
|
|
52
|
+
registers an HTTP <code>POST</code> route at the configured path, verifies the
|
|
53
|
+
<code>X-Letmepost-Signature</code> HMAC-SHA256 over the raw body, and emits the
|
|
54
|
+
event only when the signature is valid.</p>
|
|
55
|
+
|
|
56
|
+
<h3>Setup</h3>
|
|
57
|
+
<p>This node does <b>not</b> register itself with the API. Webhook endpoints
|
|
58
|
+
are created from the letmepost dashboard (the registration API is a
|
|
59
|
+
dashboard-session operation and does not accept an <code>lmp_live_</code> key).
|
|
60
|
+
Register the URL manually:</p>
|
|
61
|
+
<ol>
|
|
62
|
+
<li>Choose a <b>URL Path</b> (default <code>/letmepost-webhook</code>). The
|
|
63
|
+
public URL is your Node-RED base URL plus this path, e.g.
|
|
64
|
+
<code>https://your-host:1880/letmepost-webhook</code>. Node-RED must be
|
|
65
|
+
reachable from the public internet for letmepost to deliver to it.</li>
|
|
66
|
+
<li>In the letmepost dashboard go to <b>Settings → Webhooks</b>, add a
|
|
67
|
+
new endpoint, and paste that public URL. Pick the event types you want (or
|
|
68
|
+
all of them).</li>
|
|
69
|
+
<li>Copy the <code>signingSecret</code> (<code>whsec_…</code>) the dashboard
|
|
70
|
+
shows once at creation, and paste it into <b>Signing Secret</b> above.</li>
|
|
71
|
+
</ol>
|
|
72
|
+
|
|
73
|
+
<h3>Verification</h3>
|
|
74
|
+
<p>Each delivery carries <code>X-Letmepost-Signature: sha256=<hex></code>,
|
|
75
|
+
an HMAC-SHA256 of the raw request body keyed by the signing secret (no
|
|
76
|
+
timestamp). The node recomputes it over the exact received bytes and compares
|
|
77
|
+
in constant time. Deliveries that fail get a <code>401</code> and are dropped;
|
|
78
|
+
malformed JSON gets a <code>400</code>.</p>
|
|
79
|
+
|
|
80
|
+
<h3>Output</h3>
|
|
81
|
+
<dl class="message-properties">
|
|
82
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
83
|
+
<dd>The parsed webhook event body.</dd>
|
|
84
|
+
<dt>topic <span class="property-type">string</span></dt>
|
|
85
|
+
<dd>The event <code>type</code> (e.g. <code>post.published</code>).</dd>
|
|
86
|
+
</dl>
|
|
87
|
+
|
|
88
|
+
<h3>Notes</h3>
|
|
89
|
+
<p>The Connection (config node) is optional here and is not used to fetch the
|
|
90
|
+
secret — letmepost never returns it again. Store the secret on this node.</p>
|
|
91
|
+
</script>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const SIGNATURE_HEADER = 'x-letmepost-signature';
|
|
6
|
+
const SIGNATURE_PREFIX = 'sha256=';
|
|
7
|
+
|
|
8
|
+
module.exports = function (RED) {
|
|
9
|
+
function LetmepostWebhookNode(config) {
|
|
10
|
+
RED.nodes.createNode(this, config);
|
|
11
|
+
const node = this;
|
|
12
|
+
node.letmepostConfig = RED.nodes.getNode(config.letmepostConfig);
|
|
13
|
+
|
|
14
|
+
// The signing secret is the whsec_… value returned ONCE when the endpoint
|
|
15
|
+
// was created via POST /v1/webhook-endpoints. It lives in the config
|
|
16
|
+
// node's credentials (preferred) or, as a fallback, on this node's own
|
|
17
|
+
// credentials. We never fetch it from the API — it is not retrievable.
|
|
18
|
+
const signingSecret = resolveSecret(node, config);
|
|
19
|
+
|
|
20
|
+
let path = (config.path || '').trim();
|
|
21
|
+
if (!path) {
|
|
22
|
+
node.error('letmepost webhook: no path configured; node disabled.');
|
|
23
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no path' });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (path[0] !== '/') path = `/${path}`;
|
|
27
|
+
|
|
28
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'listening' });
|
|
29
|
+
|
|
30
|
+
// Capture the RAW request body for HMAC. Node-RED mounts a global JSON
|
|
31
|
+
// body-parser, but parsed JSON is re-serialized differently from the bytes
|
|
32
|
+
// that were signed, so HMAC over it would not match. We register our own
|
|
33
|
+
// body-parser on THIS route with a verify hook that stashes the exact
|
|
34
|
+
// received bytes on req.rawBody before any parsing happens.
|
|
35
|
+
const bodyParser = require('body-parser');
|
|
36
|
+
const captureRawBody = function (req, res, buf) {
|
|
37
|
+
req.rawBody = buf;
|
|
38
|
+
};
|
|
39
|
+
const rawTextParser = bodyParser.text({
|
|
40
|
+
type: '*/*',
|
|
41
|
+
limit: '5mb',
|
|
42
|
+
verify: captureRawBody,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const handler = function (req, res) {
|
|
46
|
+
const presented = req.headers[SIGNATURE_HEADER];
|
|
47
|
+
const rawBody = req.rawBody;
|
|
48
|
+
|
|
49
|
+
if (!signingSecret) {
|
|
50
|
+
node.warn('letmepost webhook: no signing secret set; rejecting delivery.');
|
|
51
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no secret' });
|
|
52
|
+
res.status(401).json({ message: 'Webhook has no signing secret configured.' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (rawBody === undefined) {
|
|
56
|
+
res.status(400).json({ message: 'Missing raw request body for signature verification.' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!verifySignature(rawBody, presented, signingSecret)) {
|
|
60
|
+
node.status({ fill: 'red', shape: 'ring', text: 'bad signature' });
|
|
61
|
+
res.status(401).json({ message: 'Signature verification failed.' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const bodyText = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : String(rawBody);
|
|
66
|
+
let event;
|
|
67
|
+
try {
|
|
68
|
+
event = JSON.parse(bodyText);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
res.status(400).json({ message: 'Webhook body was not valid JSON.' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Ack immediately so the sender does not retry, then emit the event.
|
|
75
|
+
res.status(200).json({ received: true });
|
|
76
|
+
|
|
77
|
+
const eventType = (event && event.type) || 'event';
|
|
78
|
+
node.status({ fill: 'green', shape: 'dot', text: eventType });
|
|
79
|
+
node.send({ payload: event, topic: eventType, req: { headers: req.headers } });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Register POST on the user-configured path against Node-RED's HTTP node
|
|
83
|
+
// app, with our raw-body parser in front of the handler.
|
|
84
|
+
RED.httpNode.post(path, rawTextParser, handler);
|
|
85
|
+
|
|
86
|
+
node.on('close', function (done) {
|
|
87
|
+
removeRoute(RED.httpNode, 'post', path);
|
|
88
|
+
node.status({});
|
|
89
|
+
done();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
RED.nodes.registerType('letmepost-webhook', LetmepostWebhookNode, {
|
|
94
|
+
credentials: {
|
|
95
|
+
signingSecret: { type: 'password' },
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function resolveSecret(node, config) {
|
|
101
|
+
if (node.credentials && node.credentials.signingSecret) {
|
|
102
|
+
return node.credentials.signingSecret;
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
node.letmepostConfig &&
|
|
106
|
+
node.letmepostConfig.credentials &&
|
|
107
|
+
node.letmepostConfig.credentials.signingSecret
|
|
108
|
+
) {
|
|
109
|
+
return node.letmepostConfig.credentials.signingSecret;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Verify an X-Letmepost-Signature header: HMAC-SHA256 over the RAW body, hex
|
|
115
|
+
// encoded, prefixed with "sha256=". No timestamp is involved.
|
|
116
|
+
function verifySignature(rawBody, presentedHeader, secret) {
|
|
117
|
+
if (typeof presentedHeader !== 'string' || presentedHeader.length === 0) return false;
|
|
118
|
+
if (typeof secret !== 'string' || secret.length === 0) return false;
|
|
119
|
+
|
|
120
|
+
const bodyBytes = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8');
|
|
121
|
+
const digest = crypto.createHmac('sha256', secret).update(bodyBytes).digest('hex');
|
|
122
|
+
const expected = `${SIGNATURE_PREFIX}${digest}`;
|
|
123
|
+
const presented = presentedHeader.startsWith(SIGNATURE_PREFIX)
|
|
124
|
+
? presentedHeader
|
|
125
|
+
: `${SIGNATURE_PREFIX}${presentedHeader}`;
|
|
126
|
+
|
|
127
|
+
const expectedBuf = Buffer.from(expected);
|
|
128
|
+
const presentedBuf = Buffer.from(presented);
|
|
129
|
+
if (expectedBuf.length !== presentedBuf.length) return false;
|
|
130
|
+
try {
|
|
131
|
+
return crypto.timingSafeEqual(expectedBuf, presentedBuf);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Remove the route we added so a redeploy does not stack duplicate handlers on
|
|
138
|
+
// the shared Express app. Express keeps registered routes on the router stack.
|
|
139
|
+
function removeRoute(app, method, path) {
|
|
140
|
+
const router = app && app._router;
|
|
141
|
+
if (!router || !Array.isArray(router.stack)) return;
|
|
142
|
+
router.stack = router.stack.filter(function (layer) {
|
|
143
|
+
if (!layer.route) return true;
|
|
144
|
+
if (layer.route.path !== path) return true;
|
|
145
|
+
return !(layer.route.methods && layer.route.methods[method]);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE_URL = 'https://api.letmepost.dev';
|
|
4
|
+
|
|
5
|
+
const CODE_TITLES = {
|
|
6
|
+
validation_failed: 'Validation failed',
|
|
7
|
+
preflight_failed: 'Preflight check failed',
|
|
8
|
+
platform_auth_failed: 'Platform authentication failed',
|
|
9
|
+
platform_rejected: 'Platform rejected the post',
|
|
10
|
+
platform_unavailable: 'Platform temporarily unavailable',
|
|
11
|
+
platform_not_enabled: 'Platform not enabled',
|
|
12
|
+
internal_error: 'Internal error',
|
|
13
|
+
unauthenticated: 'Authentication failed',
|
|
14
|
+
unauthorized: 'Not authorized',
|
|
15
|
+
not_found: 'Not found',
|
|
16
|
+
idempotency_conflict: 'Idempotency conflict',
|
|
17
|
+
rate_limited: 'Rate limited',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function normalizeBaseUrl(baseUrl) {
|
|
21
|
+
return (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildQueryString(query) {
|
|
25
|
+
const parts = [];
|
|
26
|
+
for (const key of Object.keys(query || {})) {
|
|
27
|
+
const value = query[key];
|
|
28
|
+
if (value === undefined || value === null || value === '') continue;
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
for (const entry of value) {
|
|
31
|
+
if (entry === undefined || entry === null || entry === '') continue;
|
|
32
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(entry))}`);
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return parts.length > 0 ? `?${parts.join('&')}` : '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractEnvelope(body) {
|
|
42
|
+
let parsed = body;
|
|
43
|
+
if (typeof body === 'string') {
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(body);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
51
|
+
const maybe = parsed.error;
|
|
52
|
+
if (!maybe || typeof maybe !== 'object') return null;
|
|
53
|
+
if (typeof maybe.code !== 'string' || typeof maybe.message !== 'string') return null;
|
|
54
|
+
return maybe;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// An Error subclass carrying the parsed letmepost envelope so action nodes can
|
|
58
|
+
// surface rule + remediation + requestId in node.error / node.status.
|
|
59
|
+
class LetmepostError extends Error {
|
|
60
|
+
constructor(message, statusCode, envelope, body) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'LetmepostError';
|
|
63
|
+
this.statusCode = statusCode;
|
|
64
|
+
this.envelope = envelope || null;
|
|
65
|
+
this.body = body;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function describeEnvelope(envelope) {
|
|
70
|
+
const title = CODE_TITLES[envelope.code] || 'Letmepost API error';
|
|
71
|
+
let message = `${title} (${envelope.code}): ${envelope.message}`;
|
|
72
|
+
const extra = [];
|
|
73
|
+
if (envelope.rule) extra.push(`Rule: ${envelope.rule}`);
|
|
74
|
+
if (envelope.remediation) extra.push(envelope.remediation);
|
|
75
|
+
if (envelope.docUrl) extra.push(`See ${envelope.docUrl}`);
|
|
76
|
+
if (envelope.requestId) extra.push(`Request ID: ${envelope.requestId}`);
|
|
77
|
+
if (extra.length > 0) message += ` — ${extra.join(' — ')}`;
|
|
78
|
+
return message;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Authenticated request against the letmepost.dev API. Reads the apiKey
|
|
83
|
+
* credential and baseUrl off the referenced config node and injects the
|
|
84
|
+
* Authorization: Bearer header. Resolves the parsed JSON body on 2xx, or throws
|
|
85
|
+
* a LetmepostError carrying the transparent error envelope (rule + remediation).
|
|
86
|
+
*
|
|
87
|
+
* Prefers the global fetch (Node 18+). Falls back to node-fetch only if global
|
|
88
|
+
* fetch is unavailable.
|
|
89
|
+
*/
|
|
90
|
+
async function letmepostRequest(config, options) {
|
|
91
|
+
if (!config) {
|
|
92
|
+
throw new Error('No letmepost configuration node is set. Pick a config node on this node.');
|
|
93
|
+
}
|
|
94
|
+
const apiKey = config.credentials && config.credentials.apiKey;
|
|
95
|
+
if (!apiKey) {
|
|
96
|
+
throw new Error('The letmepost configuration node has no API key set.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const baseUrl = normalizeBaseUrl(config.baseUrl);
|
|
100
|
+
const method = options.method || 'GET';
|
|
101
|
+
const query = buildQueryString(options.query);
|
|
102
|
+
const url = `${baseUrl}${options.endpoint}${query}`;
|
|
103
|
+
|
|
104
|
+
const headers = Object.assign(
|
|
105
|
+
{
|
|
106
|
+
Authorization: `Bearer ${apiKey}`,
|
|
107
|
+
Accept: 'application/json',
|
|
108
|
+
},
|
|
109
|
+
options.headers || {},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const init = { method, headers };
|
|
113
|
+
if (options.body !== undefined) {
|
|
114
|
+
headers['Content-Type'] = 'application/json';
|
|
115
|
+
init.body = JSON.stringify(options.body);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fetchFn = await resolveFetch();
|
|
119
|
+
const response = await fetchFn(url, init);
|
|
120
|
+
|
|
121
|
+
const text = await response.text();
|
|
122
|
+
let parsed;
|
|
123
|
+
if (text) {
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(text);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
parsed = text;
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
parsed = {};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
const envelope = extractEnvelope(parsed);
|
|
135
|
+
const message = envelope
|
|
136
|
+
? describeEnvelope(envelope)
|
|
137
|
+
: `letmepost request failed with HTTP ${response.status}`;
|
|
138
|
+
throw new LetmepostError(message, response.status, envelope, parsed);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return parsed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let cachedFetch;
|
|
145
|
+
async function resolveFetch() {
|
|
146
|
+
if (typeof fetch === 'function') return fetch;
|
|
147
|
+
if (cachedFetch) return cachedFetch;
|
|
148
|
+
const mod = await import('node-fetch');
|
|
149
|
+
cachedFetch = mod.default;
|
|
150
|
+
return cachedFetch;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
DEFAULT_BASE_URL,
|
|
155
|
+
LetmepostError,
|
|
156
|
+
letmepostRequest,
|
|
157
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-contrib-letmepost",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Node-RED nodes for letmepost.dev — publish to Bluesky, X/Twitter, LinkedIn, Instagram, Threads, Facebook, Pinterest and TikTok through one API, plus a signed webhook trigger.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://letmepost.dev",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"node-red",
|
|
9
|
+
"letmepost",
|
|
10
|
+
"social-media",
|
|
11
|
+
"publishing",
|
|
12
|
+
"webhook",
|
|
13
|
+
"bluesky",
|
|
14
|
+
"twitter",
|
|
15
|
+
"linkedin",
|
|
16
|
+
"instagram",
|
|
17
|
+
"threads",
|
|
18
|
+
"pinterest",
|
|
19
|
+
"tiktok"
|
|
20
|
+
],
|
|
21
|
+
"author": {
|
|
22
|
+
"name": "letmepost.dev",
|
|
23
|
+
"email": "kamal@letmepost.dev"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/letmepost/node-red-contrib-letmepost.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/letmepost/node-red-contrib-letmepost/issues"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"body-parser": "^1.20.2"
|
|
37
|
+
},
|
|
38
|
+
"node-red": {
|
|
39
|
+
"version": ">=3.0.0",
|
|
40
|
+
"nodes": {
|
|
41
|
+
"letmepost-config": "letmepost/letmepost-config.js",
|
|
42
|
+
"letmepost-publish": "letmepost/letmepost-publish.js",
|
|
43
|
+
"letmepost-get-post": "letmepost/letmepost-get-post.js",
|
|
44
|
+
"letmepost-list-accounts": "letmepost/letmepost-list-accounts.js",
|
|
45
|
+
"letmepost-webhook": "letmepost/letmepost-webhook.js"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"letmepost",
|
|
50
|
+
"README.md",
|
|
51
|
+
"LICENSE"
|
|
52
|
+
]
|
|
53
|
+
}
|