n8n-nodes-postwire 0.1.0 → 0.2.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/dist/credentials/PostWireApi.credentials.d.ts +9 -0
- package/dist/credentials/PostWireApi.credentials.js +44 -0
- package/dist/nodes/PostWire/PostWire.node.d.ts +5 -0
- package/dist/nodes/PostWire/PostWire.node.js +297 -0
- package/index.js +2 -0
- package/package.json +34 -9
- package/credentials/PostWireApi.credentials.js +0 -44
- package/nodes/PostWire/PostWire.node.js +0 -296
- /package/{credentials → dist/credentials}/postwire.svg +0 -0
- /package/{nodes → dist/nodes}/PostWire/postwire.svg +0 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { IAuthenticateGeneric, ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';
|
|
2
|
+
export declare class PostWireApi implements ICredentialType {
|
|
3
|
+
name: string;
|
|
4
|
+
displayName: string;
|
|
5
|
+
documentationUrl: string;
|
|
6
|
+
properties: INodeProperties[];
|
|
7
|
+
authenticate: IAuthenticateGeneric;
|
|
8
|
+
test: ICredentialTestRequest;
|
|
9
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PostWireApi = void 0;
|
|
4
|
+
// PostWire authenticates with a single bearer key. The `test` block below is what makes n8n's
|
|
5
|
+
// "Test connection" button meaningful: /api/me returns the plan, the month's usage and the
|
|
6
|
+
// connected accounts, so a wrong key fails immediately instead of at publish time.
|
|
7
|
+
class PostWireApi {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.name = 'postWireApi';
|
|
10
|
+
this.displayName = 'PostWire API';
|
|
11
|
+
this.documentationUrl = 'https://postwire.io/n8n-social-media-automation/';
|
|
12
|
+
this.properties = [
|
|
13
|
+
{
|
|
14
|
+
displayName: 'API Key',
|
|
15
|
+
name: 'apiKey',
|
|
16
|
+
type: 'string',
|
|
17
|
+
typeOptions: { password: true },
|
|
18
|
+
default: '',
|
|
19
|
+
required: true,
|
|
20
|
+
placeholder: 'pw_live_...',
|
|
21
|
+
description: 'Your PostWire API key. Get one free at https://postwire.io/dashboard.html — the free plan covers one brand, every network it connects, and 30 posts a month, with no card.',
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
this.authenticate = {
|
|
25
|
+
type: 'generic',
|
|
26
|
+
properties: {
|
|
27
|
+
headers: {
|
|
28
|
+
Authorization: '=Bearer {{$credentials.apiKey}}',
|
|
29
|
+
// Read server-side to tell n8n traffic apart from the dashboard. npm download counts
|
|
30
|
+
// turned out to be a scanner floor, so this header is the only honest usage signal.
|
|
31
|
+
'X-PostWire-Source': 'n8n',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
this.test = {
|
|
36
|
+
request: {
|
|
37
|
+
baseURL: 'https://postwire.io',
|
|
38
|
+
url: '/api/me',
|
|
39
|
+
method: 'GET',
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.PostWireApi = PostWireApi;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type IExecuteFunctions, type INodeExecutionData, type INodeType, type INodeTypeDescription } from 'n8n-workflow';
|
|
2
|
+
export declare class PostWire implements INodeType {
|
|
3
|
+
description: INodeTypeDescription;
|
|
4
|
+
execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
|
|
5
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PostWire = void 0;
|
|
4
|
+
const n8n_workflow_1 = require("n8n-workflow");
|
|
5
|
+
const BASE = 'https://postwire.io';
|
|
6
|
+
// Every network PostWire can publish to. `mediaNeed` mirrors the server's own rule so the node can
|
|
7
|
+
// warn in the editor rather than letting the call fail — TikTok and YouTube refuse a post with no
|
|
8
|
+
// video, and Instagram refuses one with no media at all.
|
|
9
|
+
const PLATFORMS = [
|
|
10
|
+
{ name: 'TikTok', value: 'tiktok', mediaNeed: 'video' },
|
|
11
|
+
{ name: 'Instagram', value: 'instagram', mediaNeed: 'any' },
|
|
12
|
+
{ name: 'YouTube', value: 'youtube', mediaNeed: 'video' },
|
|
13
|
+
{ name: 'LinkedIn', value: 'linkedin', mediaNeed: null },
|
|
14
|
+
{ name: 'X (Twitter)', value: 'x', mediaNeed: null },
|
|
15
|
+
{ name: 'Facebook', value: 'facebook', mediaNeed: null },
|
|
16
|
+
{ name: 'Reddit', value: 'reddit', mediaNeed: null },
|
|
17
|
+
{ name: 'Bluesky', value: 'bluesky', mediaNeed: null },
|
|
18
|
+
{ name: 'Mastodon', value: 'mastodon', mediaNeed: null },
|
|
19
|
+
{ name: 'Telegram', value: 'telegram', mediaNeed: null },
|
|
20
|
+
{ name: 'Discord', value: 'discord', mediaNeed: null },
|
|
21
|
+
];
|
|
22
|
+
const needOf = (value) => PLATFORMS.find((p) => p.value === value)?.mediaNeed ?? null;
|
|
23
|
+
class PostWire {
|
|
24
|
+
constructor() {
|
|
25
|
+
this.description = {
|
|
26
|
+
displayName: 'PostWire',
|
|
27
|
+
name: 'postWire',
|
|
28
|
+
icon: 'file:postwire.svg',
|
|
29
|
+
group: ['output'],
|
|
30
|
+
version: 1,
|
|
31
|
+
subtitle: '={{$parameter["operation"]}}',
|
|
32
|
+
description: 'Publish one idea natively to every social network',
|
|
33
|
+
defaults: { name: 'PostWire' },
|
|
34
|
+
inputs: ['main'],
|
|
35
|
+
outputs: ['main'],
|
|
36
|
+
credentials: [{ name: 'postWireApi', required: true }],
|
|
37
|
+
requestDefaults: {
|
|
38
|
+
baseURL: BASE,
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
},
|
|
41
|
+
properties: [
|
|
42
|
+
{
|
|
43
|
+
displayName: 'Operation',
|
|
44
|
+
name: 'operation',
|
|
45
|
+
type: 'options',
|
|
46
|
+
noDataExpression: true,
|
|
47
|
+
options: [
|
|
48
|
+
{
|
|
49
|
+
name: 'Write a Post per Network',
|
|
50
|
+
value: 'generate',
|
|
51
|
+
description: 'One prompt in, a native draft out for each network — right length, hashtags and format per platform. Does not publish.',
|
|
52
|
+
action: 'Write a native post for each network',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'Publish',
|
|
56
|
+
value: 'publish',
|
|
57
|
+
description: 'Publish to one or more networks in a single call',
|
|
58
|
+
action: 'Publish to social networks',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'Write and Publish',
|
|
62
|
+
value: 'generateAndPublish',
|
|
63
|
+
description: 'Write a native draft per network, then publish them',
|
|
64
|
+
action: 'Write and publish in one step',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'Schedule',
|
|
68
|
+
value: 'schedule',
|
|
69
|
+
description: 'Queue a post for a later time',
|
|
70
|
+
action: 'Schedule a post',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'Get Account',
|
|
74
|
+
value: 'me',
|
|
75
|
+
description: 'Plan, usage this month and connected accounts',
|
|
76
|
+
action: 'Get the PostWire account',
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
default: 'generateAndPublish',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
displayName: 'Networks',
|
|
83
|
+
name: 'platforms',
|
|
84
|
+
type: 'multiOptions',
|
|
85
|
+
options: PLATFORMS.map((p) => ({ name: p.name, value: p.value })),
|
|
86
|
+
default: ['x', 'linkedin'],
|
|
87
|
+
required: true,
|
|
88
|
+
description: 'Connect these in the PostWire dashboard first. TikTok, Instagram and YouTube are one OAuth click — PostWire carries the platform approvals, so there is no app review on your side.',
|
|
89
|
+
displayOptions: {
|
|
90
|
+
show: { operation: ['generate', 'publish', 'generateAndPublish', 'schedule'] },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
displayName: 'Idea',
|
|
95
|
+
name: 'prompt',
|
|
96
|
+
type: 'string',
|
|
97
|
+
typeOptions: { rows: 3 },
|
|
98
|
+
default: '',
|
|
99
|
+
required: true,
|
|
100
|
+
placeholder: 'We shipped scheduling today — one idea, a native post per network',
|
|
101
|
+
description: 'What you want to say. More detail gives a better draft.',
|
|
102
|
+
displayOptions: { show: { operation: ['generate', 'generateAndPublish'] } },
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
displayName: 'Text',
|
|
106
|
+
name: 'text',
|
|
107
|
+
type: 'string',
|
|
108
|
+
typeOptions: { rows: 3 },
|
|
109
|
+
default: '',
|
|
110
|
+
description: 'Posted exactly as written to every selected network',
|
|
111
|
+
displayOptions: { show: { operation: ['publish', 'schedule'] } },
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
displayName: 'Media URL',
|
|
115
|
+
name: 'mediaUrl',
|
|
116
|
+
type: 'string',
|
|
117
|
+
default: '',
|
|
118
|
+
placeholder: 'https://example.com/clip.mp4',
|
|
119
|
+
description: 'A direct link to an .mp4 or an image. TikTok and YouTube will not accept a post without a video, and Instagram needs a photo or a video. A Google Drive share link does not work — it returns a web page, not a file.',
|
|
120
|
+
displayOptions: {
|
|
121
|
+
show: { operation: ['generate', 'publish', 'generateAndPublish', 'schedule'] },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
displayName: 'Publish At',
|
|
126
|
+
name: 'runAt',
|
|
127
|
+
type: 'dateTime',
|
|
128
|
+
default: '',
|
|
129
|
+
required: true,
|
|
130
|
+
description: 'When to publish. PostWire validates the post now, so a missing video is caught here rather than at 7am tomorrow.',
|
|
131
|
+
displayOptions: { show: { operation: ['schedule'] } },
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
displayName: 'Options',
|
|
135
|
+
name: 'options',
|
|
136
|
+
type: 'collection',
|
|
137
|
+
placeholder: 'Add option',
|
|
138
|
+
default: {},
|
|
139
|
+
displayOptions: {
|
|
140
|
+
show: { operation: ['generate', 'publish', 'generateAndPublish', 'schedule'] },
|
|
141
|
+
},
|
|
142
|
+
options: [
|
|
143
|
+
{
|
|
144
|
+
displayName: 'Brand',
|
|
145
|
+
name: 'brandId',
|
|
146
|
+
type: 'string',
|
|
147
|
+
default: '',
|
|
148
|
+
description: 'Publish as a specific brand. Leave empty for your only brand.',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
displayName: 'Brand Voice',
|
|
152
|
+
name: 'brandVoice',
|
|
153
|
+
type: 'string',
|
|
154
|
+
default: '',
|
|
155
|
+
description: 'A sentence describing how you want to sound, applied to every generated draft',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
displayName: 'Title (YouTube)',
|
|
159
|
+
name: 'title',
|
|
160
|
+
type: 'string',
|
|
161
|
+
default: '',
|
|
162
|
+
description: 'Overrides the generated YouTube title',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
displayName: 'Visibility',
|
|
166
|
+
name: 'privacy',
|
|
167
|
+
type: 'options',
|
|
168
|
+
options: [
|
|
169
|
+
{ name: 'Private', value: 'private' },
|
|
170
|
+
{ name: 'Public', value: 'public' },
|
|
171
|
+
{ name: 'Unlisted', value: 'unlisted' },
|
|
172
|
+
],
|
|
173
|
+
default: 'public',
|
|
174
|
+
description: 'Used by TikTok and YouTube',
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
async execute() {
|
|
182
|
+
const items = this.getInputData();
|
|
183
|
+
const out = [];
|
|
184
|
+
for (let i = 0; i < items.length; i++) {
|
|
185
|
+
try {
|
|
186
|
+
const operation = this.getNodeParameter('operation', i);
|
|
187
|
+
if (operation === 'me') {
|
|
188
|
+
const me = (await this.helpers.httpRequestWithAuthentication.call(this, 'postWireApi', {
|
|
189
|
+
method: 'GET',
|
|
190
|
+
url: `${BASE}/api/me`,
|
|
191
|
+
json: true,
|
|
192
|
+
}));
|
|
193
|
+
out.push({ json: me, pairedItem: { item: i } });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const platforms = this.getNodeParameter('platforms', i);
|
|
197
|
+
const opts = this.getNodeParameter('options', i, {});
|
|
198
|
+
const mediaUrl = (this.getNodeParameter('mediaUrl', i, '') || '').trim();
|
|
199
|
+
if (!platforms?.length) {
|
|
200
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Pick at least one network.', {
|
|
201
|
+
itemIndex: i,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// Media rules, checked before the request so the message names the fix rather than the symptom.
|
|
205
|
+
const isVideo = /\.(mp4|mov|webm|m3u8|avi|mkv|m4v)(\?|$)/i.test(mediaUrl);
|
|
206
|
+
const blocked = platforms.filter((p) => {
|
|
207
|
+
const need = needOf(p);
|
|
208
|
+
if (!need)
|
|
209
|
+
return false;
|
|
210
|
+
if (need === 'video')
|
|
211
|
+
return !isVideo;
|
|
212
|
+
return !mediaUrl;
|
|
213
|
+
});
|
|
214
|
+
if (blocked.length) {
|
|
215
|
+
const wantsVideo = blocked.some((p) => needOf(p) === 'video');
|
|
216
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `${blocked.join(' and ')} will not accept a post without ${wantsVideo ? 'a video' : 'an image or video'}. Set Media URL to a direct file link, or remove ${blocked.length > 1 ? 'those networks' : 'that network'} from the list.`, { itemIndex: i });
|
|
217
|
+
}
|
|
218
|
+
const photoUrl = mediaUrl && !isVideo ? mediaUrl : undefined;
|
|
219
|
+
const videoUrl = isVideo ? mediaUrl : undefined;
|
|
220
|
+
let perPlatform;
|
|
221
|
+
if (operation === 'generate' || operation === 'generateAndPublish') {
|
|
222
|
+
const prompt = this.getNodeParameter('prompt', i);
|
|
223
|
+
const gen = (await this.helpers.httpRequestWithAuthentication.call(this, 'postWireApi', {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
url: `${BASE}/api/generate`,
|
|
226
|
+
body: {
|
|
227
|
+
prompt,
|
|
228
|
+
platforms,
|
|
229
|
+
media_url: mediaUrl || undefined,
|
|
230
|
+
brand_voice: opts.brandVoice || undefined,
|
|
231
|
+
},
|
|
232
|
+
json: true,
|
|
233
|
+
}));
|
|
234
|
+
perPlatform = gen.drafts;
|
|
235
|
+
if (operation === 'generate') {
|
|
236
|
+
out.push({ json: gen, pairedItem: { item: i } });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const body = {
|
|
241
|
+
platforms,
|
|
242
|
+
per_platform: perPlatform,
|
|
243
|
+
text: perPlatform ? undefined : this.getNodeParameter('text', i, ''),
|
|
244
|
+
photo_url: photoUrl,
|
|
245
|
+
video_url: videoUrl,
|
|
246
|
+
title: opts.title || undefined,
|
|
247
|
+
privacy: opts.privacy || undefined,
|
|
248
|
+
brand_id: opts.brandId || undefined,
|
|
249
|
+
};
|
|
250
|
+
if (operation === 'schedule') {
|
|
251
|
+
const runAt = this.getNodeParameter('runAt', i);
|
|
252
|
+
const scheduled = (await this.helpers.httpRequestWithAuthentication.call(this, 'postWireApi', {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
url: `${BASE}/api/schedule`,
|
|
255
|
+
body: { ...body, run_at: runAt },
|
|
256
|
+
json: true,
|
|
257
|
+
}));
|
|
258
|
+
out.push({ json: scheduled, pairedItem: { item: i } });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const response = (await this.helpers.httpRequestWithAuthentication.call(this, 'postWireApi', {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
url: `${BASE}/api/post`,
|
|
264
|
+
body,
|
|
265
|
+
json: true,
|
|
266
|
+
}));
|
|
267
|
+
// One n8n item per network, so a Filter or IF node downstream can act on the failures
|
|
268
|
+
// alone instead of unpacking an array by hand.
|
|
269
|
+
const results = (response.results || [response]).filter(Boolean);
|
|
270
|
+
for (const res of results) {
|
|
271
|
+
out.push({ json: res, pairedItem: { item: i } });
|
|
272
|
+
}
|
|
273
|
+
// A 200 whose every network failed used to leave the node green with the error buried
|
|
274
|
+
// in the output, so a scheduled flow could publish nothing for weeks and never say so.
|
|
275
|
+
// If not one network went out, that is a failed execution.
|
|
276
|
+
const failed = results.filter((res) => res?.ok === false);
|
|
277
|
+
if (results.length && failed.length === results.length) {
|
|
278
|
+
const why = failed
|
|
279
|
+
.map((f) => `${f.platform || '?'}: ${f.error || f.code || 'failed'}`)
|
|
280
|
+
.join('; ');
|
|
281
|
+
const limited = failed.find((f) => f.code === 'brand_limit_reached' || f.upgrade_url);
|
|
282
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `PostWire published to none of the ${results.length} selected network(s). ${why}` +
|
|
283
|
+
(limited?.upgrade_url ? ` — upgrade: ${limited.upgrade_url}` : ''), { itemIndex: i });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (this.continueOnFail()) {
|
|
288
|
+
out.push({ json: { error: error.message }, pairedItem: { item: i } });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return [out];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
exports.PostWire = PostWire;
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n8n-nodes-postwire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Publish one idea natively to TikTok, Instagram, YouTube, LinkedIn, X, Bluesky, Mastodon, Telegram, Discord and Reddit from n8n — a different post written for each network, via PostWire.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"n8n-community-node-package",
|
|
@@ -15,18 +15,43 @@
|
|
|
15
15
|
],
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"homepage": "https://postwire.io",
|
|
18
|
-
"author": {
|
|
19
|
-
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "PostWire",
|
|
20
|
+
"email": "privacy@postwire.io"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/Perufitlife/n8n-nodes-postwire.git"
|
|
25
|
+
},
|
|
20
26
|
"main": "index.js",
|
|
21
27
|
"scripts": {
|
|
22
|
-
"
|
|
28
|
+
"build": "tsc && node scripts-copy-icons.mjs",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
23
30
|
},
|
|
24
|
-
"files": [
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"index.js",
|
|
34
|
+
"LICENSE.md",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
25
37
|
"n8n": {
|
|
26
38
|
"n8nNodesApiVersion": 1,
|
|
27
|
-
"credentials": [
|
|
28
|
-
|
|
39
|
+
"credentials": [
|
|
40
|
+
"dist/credentials/PostWireApi.credentials.js"
|
|
41
|
+
],
|
|
42
|
+
"nodes": [
|
|
43
|
+
"dist/nodes/PostWire/PostWire.node.js"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"n8n-workflow": "*"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20.15"
|
|
29
51
|
},
|
|
30
|
-
"
|
|
31
|
-
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.10.2",
|
|
54
|
+
"n8n-workflow": "*",
|
|
55
|
+
"typescript": "^5.7.2"
|
|
56
|
+
}
|
|
32
57
|
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.PostWireApi = void 0;
|
|
4
|
-
|
|
5
|
-
// PostWire authenticates with a single bearer key. The `test` block below is what makes n8n's
|
|
6
|
-
// "Test connection" button meaningful: /api/me returns the plan, the month's usage and the
|
|
7
|
-
// connected accounts, so a wrong key fails immediately instead of at publish time.
|
|
8
|
-
class PostWireApi {
|
|
9
|
-
constructor() {
|
|
10
|
-
this.name = 'postWireApi';
|
|
11
|
-
this.displayName = 'PostWire API';
|
|
12
|
-
this.documentationUrl = 'https://postwire.io/mcp/';
|
|
13
|
-
this.properties = [
|
|
14
|
-
{
|
|
15
|
-
displayName: 'API Key',
|
|
16
|
-
name: 'apiKey',
|
|
17
|
-
type: 'string',
|
|
18
|
-
typeOptions: { password: true },
|
|
19
|
-
default: '',
|
|
20
|
-
required: true,
|
|
21
|
-
placeholder: 'pw_live_...',
|
|
22
|
-
description:
|
|
23
|
-
'Your PostWire API key. Get one free at https://postwire.io/dashboard.html — the free plan covers one brand, every network it connects, and 30 posts a month, with no card.',
|
|
24
|
-
},
|
|
25
|
-
];
|
|
26
|
-
this.authenticate = {
|
|
27
|
-
type: 'generic',
|
|
28
|
-
properties: {
|
|
29
|
-
headers: {
|
|
30
|
-
Authorization: '=Bearer {{$credentials.apiKey}}',
|
|
31
|
-
'X-PostWire-Source': 'n8n',
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
};
|
|
35
|
-
this.test = {
|
|
36
|
-
request: {
|
|
37
|
-
baseURL: 'https://postwire.io',
|
|
38
|
-
url: '/api/me',
|
|
39
|
-
method: 'GET',
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
exports.PostWireApi = PostWireApi;
|
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.PostWire = void 0;
|
|
4
|
-
|
|
5
|
-
const { NodeOperationError } = require("n8n-workflow");
|
|
6
|
-
|
|
7
|
-
const BASE = "https://postwire.io";
|
|
8
|
-
|
|
9
|
-
// Every network PostWire can publish to. `mediaNeed` mirrors the server's own rule so the node can
|
|
10
|
-
// warn in the editor rather than letting the call fail — TikTok and YouTube refuse a post with no
|
|
11
|
-
// video, and Instagram refuses one with no media at all.
|
|
12
|
-
const PLATFORMS = [
|
|
13
|
-
{ name: "TikTok", value: "tiktok", mediaNeed: "video" },
|
|
14
|
-
{ name: "Instagram", value: "instagram", mediaNeed: "any" },
|
|
15
|
-
{ name: "YouTube", value: "youtube", mediaNeed: "video" },
|
|
16
|
-
{ name: "LinkedIn", value: "linkedin", mediaNeed: null },
|
|
17
|
-
{ name: "X (Twitter)", value: "x", mediaNeed: null },
|
|
18
|
-
{ name: "Facebook", value: "facebook", mediaNeed: null },
|
|
19
|
-
{ name: "Reddit", value: "reddit", mediaNeed: null },
|
|
20
|
-
{ name: "Bluesky", value: "bluesky", mediaNeed: null },
|
|
21
|
-
{ name: "Mastodon", value: "mastodon", mediaNeed: null },
|
|
22
|
-
{ name: "Telegram", value: "telegram", mediaNeed: null },
|
|
23
|
-
{ name: "Discord", value: "discord", mediaNeed: null },
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
class PostWire {
|
|
27
|
-
constructor() {
|
|
28
|
-
this.description = {
|
|
29
|
-
displayName: "PostWire",
|
|
30
|
-
name: "postWire",
|
|
31
|
-
icon: "file:postwire.svg",
|
|
32
|
-
group: ["output"],
|
|
33
|
-
version: 1,
|
|
34
|
-
subtitle: '={{$parameter["operation"]}}',
|
|
35
|
-
description: "Publish one idea natively to every social network",
|
|
36
|
-
defaults: { name: "PostWire" },
|
|
37
|
-
inputs: ["main"],
|
|
38
|
-
outputs: ["main"],
|
|
39
|
-
credentials: [{ name: "postWireApi", required: true }],
|
|
40
|
-
requestDefaults: {
|
|
41
|
-
baseURL: BASE,
|
|
42
|
-
headers: { "Content-Type": "application/json" },
|
|
43
|
-
},
|
|
44
|
-
properties: [
|
|
45
|
-
{
|
|
46
|
-
displayName: "Operation",
|
|
47
|
-
name: "operation",
|
|
48
|
-
type: "options",
|
|
49
|
-
noDataExpression: true,
|
|
50
|
-
options: [
|
|
51
|
-
{
|
|
52
|
-
name: "Write a Post per Network",
|
|
53
|
-
value: "generate",
|
|
54
|
-
description:
|
|
55
|
-
"One prompt in, a native draft out for each network — right length, hashtags and format per platform. Does not publish.",
|
|
56
|
-
action: "Write a native post for each network",
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
name: "Publish",
|
|
60
|
-
value: "publish",
|
|
61
|
-
description: "Publish to one or more networks in a single call",
|
|
62
|
-
action: "Publish to social networks",
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
name: "Write and Publish",
|
|
66
|
-
value: "generateAndPublish",
|
|
67
|
-
description: "Write a native draft per network, then publish them",
|
|
68
|
-
action: "Write and publish in one step",
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
name: "Schedule",
|
|
72
|
-
value: "schedule",
|
|
73
|
-
description: "Queue a post for a later time",
|
|
74
|
-
action: "Schedule a post",
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
name: "Get Account",
|
|
78
|
-
value: "me",
|
|
79
|
-
description: "Plan, usage this month and connected accounts",
|
|
80
|
-
action: "Get the PostWire account",
|
|
81
|
-
},
|
|
82
|
-
],
|
|
83
|
-
default: "generateAndPublish",
|
|
84
|
-
},
|
|
85
|
-
|
|
86
|
-
{
|
|
87
|
-
displayName: "Networks",
|
|
88
|
-
name: "platforms",
|
|
89
|
-
type: "multiOptions",
|
|
90
|
-
options: PLATFORMS.map((p) => ({ name: p.name, value: p.value })),
|
|
91
|
-
default: ["x", "linkedin"],
|
|
92
|
-
required: true,
|
|
93
|
-
description:
|
|
94
|
-
"Connect these in the PostWire dashboard first. TikTok, Instagram and YouTube are one OAuth click — PostWire carries the platform approvals, so there is no app review on your side.",
|
|
95
|
-
displayOptions: { show: { operation: ["generate", "publish", "generateAndPublish", "schedule"] } },
|
|
96
|
-
},
|
|
97
|
-
|
|
98
|
-
{
|
|
99
|
-
displayName: "Idea",
|
|
100
|
-
name: "prompt",
|
|
101
|
-
type: "string",
|
|
102
|
-
typeOptions: { rows: 3 },
|
|
103
|
-
default: "",
|
|
104
|
-
required: true,
|
|
105
|
-
placeholder: "We shipped scheduling today — one idea, a native post per network",
|
|
106
|
-
description: "What you want to say. More detail gives a better draft.",
|
|
107
|
-
displayOptions: { show: { operation: ["generate", "generateAndPublish"] } },
|
|
108
|
-
},
|
|
109
|
-
|
|
110
|
-
{
|
|
111
|
-
displayName: "Text",
|
|
112
|
-
name: "text",
|
|
113
|
-
type: "string",
|
|
114
|
-
typeOptions: { rows: 3 },
|
|
115
|
-
default: "",
|
|
116
|
-
description: "Posted exactly as written to every selected network",
|
|
117
|
-
displayOptions: { show: { operation: ["publish", "schedule"] } },
|
|
118
|
-
},
|
|
119
|
-
|
|
120
|
-
{
|
|
121
|
-
displayName: "Media URL",
|
|
122
|
-
name: "mediaUrl",
|
|
123
|
-
type: "string",
|
|
124
|
-
default: "",
|
|
125
|
-
placeholder: "https://example.com/clip.mp4",
|
|
126
|
-
description:
|
|
127
|
-
"A direct link to an .mp4 or an image. TikTok and YouTube will not accept a post without a video, and Instagram needs a photo or a video. A Google Drive share link does not work — it returns a web page, not a file.",
|
|
128
|
-
displayOptions: { show: { operation: ["generate", "publish", "generateAndPublish", "schedule"] } },
|
|
129
|
-
},
|
|
130
|
-
|
|
131
|
-
{
|
|
132
|
-
displayName: "Publish At",
|
|
133
|
-
name: "runAt",
|
|
134
|
-
type: "dateTime",
|
|
135
|
-
default: "",
|
|
136
|
-
required: true,
|
|
137
|
-
description: "When to publish. PostWire validates the post now, so a missing video is caught here rather than at 7am tomorrow.",
|
|
138
|
-
displayOptions: { show: { operation: ["schedule"] } },
|
|
139
|
-
},
|
|
140
|
-
|
|
141
|
-
{
|
|
142
|
-
displayName: "Options",
|
|
143
|
-
name: "options",
|
|
144
|
-
type: "collection",
|
|
145
|
-
placeholder: "Add option",
|
|
146
|
-
default: {},
|
|
147
|
-
displayOptions: { show: { operation: ["generate", "publish", "generateAndPublish", "schedule"] } },
|
|
148
|
-
options: [
|
|
149
|
-
{
|
|
150
|
-
displayName: "Title (YouTube)",
|
|
151
|
-
name: "title",
|
|
152
|
-
type: "string",
|
|
153
|
-
default: "",
|
|
154
|
-
description: "Overrides the generated YouTube title",
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
displayName: "Brand",
|
|
158
|
-
name: "brandId",
|
|
159
|
-
type: "string",
|
|
160
|
-
default: "",
|
|
161
|
-
description: "Publish as a specific brand. Leave empty for your only brand.",
|
|
162
|
-
},
|
|
163
|
-
{
|
|
164
|
-
displayName: "Visibility",
|
|
165
|
-
name: "privacy",
|
|
166
|
-
type: "options",
|
|
167
|
-
options: [
|
|
168
|
-
{ name: "Public", value: "public" },
|
|
169
|
-
{ name: "Private", value: "private" },
|
|
170
|
-
{ name: "Unlisted", value: "unlisted" },
|
|
171
|
-
],
|
|
172
|
-
default: "public",
|
|
173
|
-
description: "Used by TikTok and YouTube",
|
|
174
|
-
},
|
|
175
|
-
{
|
|
176
|
-
displayName: "Brand Voice",
|
|
177
|
-
name: "brandVoice",
|
|
178
|
-
type: "string",
|
|
179
|
-
default: "",
|
|
180
|
-
description: "A sentence describing how you want to sound, applied to every generated draft",
|
|
181
|
-
},
|
|
182
|
-
],
|
|
183
|
-
},
|
|
184
|
-
],
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async execute() {
|
|
189
|
-
const items = this.getInputData();
|
|
190
|
-
const out = [];
|
|
191
|
-
|
|
192
|
-
for (let i = 0; i < items.length; i++) {
|
|
193
|
-
try {
|
|
194
|
-
const operation = this.getNodeParameter("operation", i);
|
|
195
|
-
|
|
196
|
-
if (operation === "me") {
|
|
197
|
-
const me = await this.helpers.httpRequestWithAuthentication.call(this, "postWireApi", {
|
|
198
|
-
method: "GET",
|
|
199
|
-
url: `${BASE}/api/me`,
|
|
200
|
-
json: true,
|
|
201
|
-
});
|
|
202
|
-
out.push({ json: me, pairedItem: { item: i } });
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const platforms = this.getNodeParameter("platforms", i);
|
|
207
|
-
const opts = this.getNodeParameter("options", i, {});
|
|
208
|
-
const mediaUrl = (this.getNodeParameter("mediaUrl", i, "") || "").trim();
|
|
209
|
-
|
|
210
|
-
if (!platforms || !platforms.length) {
|
|
211
|
-
throw new NodeOperationError(this.getNode(), "Pick at least one network.", { itemIndex: i });
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Media rules, checked before the request so the message names the fix rather than the symptom.
|
|
215
|
-
const isVideo = /\.(mp4|mov|webm|m3u8|avi|mkv|m4v)(\?|$)/i.test(mediaUrl);
|
|
216
|
-
const blocked = platforms.filter((p) => {
|
|
217
|
-
const need = (PLATFORMS.find((x) => x.value === p) || {}).mediaNeed;
|
|
218
|
-
if (!need) return false;
|
|
219
|
-
if (need === "video") return !isVideo;
|
|
220
|
-
return !mediaUrl;
|
|
221
|
-
});
|
|
222
|
-
if (blocked.length) {
|
|
223
|
-
throw new NodeOperationError(
|
|
224
|
-
this.getNode(),
|
|
225
|
-
`${blocked.join(" and ")} will not accept a post without ${blocked.some((p) => (PLATFORMS.find((x) => x.value === p) || {}).mediaNeed === "video") ? "a video" : "an image or video"}. Set Media URL to a direct file link, or remove ${blocked.length > 1 ? "those networks" : "that network"} from the list.`,
|
|
226
|
-
{ itemIndex: i },
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const photo_url = mediaUrl && !isVideo ? mediaUrl : undefined;
|
|
231
|
-
const video_url = isVideo ? mediaUrl : undefined;
|
|
232
|
-
|
|
233
|
-
let per_platform;
|
|
234
|
-
if (operation === "generate" || operation === "generateAndPublish") {
|
|
235
|
-
const prompt = this.getNodeParameter("prompt", i);
|
|
236
|
-
const gen = await this.helpers.httpRequestWithAuthentication.call(this, "postWireApi", {
|
|
237
|
-
method: "POST",
|
|
238
|
-
url: `${BASE}/api/generate`,
|
|
239
|
-
body: { prompt, platforms, media_url: mediaUrl || undefined, brand_voice: opts.brandVoice || undefined },
|
|
240
|
-
json: true,
|
|
241
|
-
});
|
|
242
|
-
per_platform = gen.drafts;
|
|
243
|
-
if (operation === "generate") {
|
|
244
|
-
out.push({ json: gen, pairedItem: { item: i } });
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const body = {
|
|
250
|
-
platforms,
|
|
251
|
-
per_platform,
|
|
252
|
-
text: per_platform ? undefined : this.getNodeParameter("text", i, ""),
|
|
253
|
-
photo_url,
|
|
254
|
-
video_url,
|
|
255
|
-
title: opts.title || undefined,
|
|
256
|
-
privacy: opts.privacy || undefined,
|
|
257
|
-
brand_id: opts.brandId || undefined,
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
if (operation === "schedule") {
|
|
261
|
-
const runAt = this.getNodeParameter("runAt", i);
|
|
262
|
-
const r = await this.helpers.httpRequestWithAuthentication.call(this, "postWireApi", {
|
|
263
|
-
method: "POST",
|
|
264
|
-
url: `${BASE}/api/schedule`,
|
|
265
|
-
body: { ...body, run_at: runAt },
|
|
266
|
-
json: true,
|
|
267
|
-
});
|
|
268
|
-
out.push({ json: r, pairedItem: { item: i } });
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const r = await this.helpers.httpRequestWithAuthentication.call(this, "postWireApi", {
|
|
273
|
-
method: "POST",
|
|
274
|
-
url: `${BASE}/api/post`,
|
|
275
|
-
body,
|
|
276
|
-
json: true,
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
// One n8n item per network, so a Filter or IF node downstream can act on the failures alone
|
|
280
|
-
// instead of unpacking an array by hand.
|
|
281
|
-
for (const res of r.results || [r]) {
|
|
282
|
-
out.push({ json: res, pairedItem: { item: i } });
|
|
283
|
-
}
|
|
284
|
-
} catch (error) {
|
|
285
|
-
if (this.continueOnFail()) {
|
|
286
|
-
out.push({ json: { error: error.message }, pairedItem: { item: i } });
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
throw error;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
return [out];
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
exports.PostWire = PostWire;
|
|
File without changes
|
|
File without changes
|