openwriter 0.6.2 → 0.6.4
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/client/assets/index-C0ddsTTl.css +1 -0
- package/dist/client/assets/index-IGiG5fjk.js +209 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
- package/dist/plugins/authors-voice/dist/index.js +203 -0
- package/dist/plugins/authors-voice/package.json +23 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +88 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +54 -0
- package/dist/plugins/publish/dist/helpers.js +185 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +697 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +364 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +217 -0
- package/dist/plugins/x-api/package.json +26 -0
- package/dist/server/blog-routes.js +160 -0
- package/dist/server/connection-routes.js +46 -0
- package/dist/server/documents.js +4 -2
- package/dist/server/index.js +4 -0
- package/dist/server/install-skill.js +10 -0
- package/dist/server/plugin-discovery.js +35 -8
- package/dist/server/plugin-manager.js +2 -2
- package/dist/server/scheduler-routes.js +191 -0
- package/dist/server/state.js +32 -1
- package/dist/server/ws.js +40 -5
- package/package.json +3 -2
- package/skill/SKILL.md +41 -3
- package/skill/docs/welcome.md +21 -0
- package/dist/client/assets/index-BjnZD0j5.js +0 -209
- package/dist/client/assets/index-DIJCM4cA.css +0 -1
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { getServerModules, documentToEmail, extractLocalImages, publishFetch, } from './helpers.js';
|
|
3
|
+
export function newsletterTools(config) {
|
|
4
|
+
return [
|
|
5
|
+
{
|
|
6
|
+
name: 'send_newsletter',
|
|
7
|
+
description: 'Send the current document as a newsletter. Sends to all subscribers, or to a single test address if test_email is provided. Supports subscriber selection via subscriber_ids or exclude_issue_id.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
subject: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
description: 'Email subject line. Defaults to the document title if not provided.',
|
|
14
|
+
},
|
|
15
|
+
format: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
enum: ['html', 'plaintext'],
|
|
18
|
+
description: 'Email format: "html" (rich formatted) or "plaintext" (plain text only). Defaults to "html".',
|
|
19
|
+
},
|
|
20
|
+
test_email: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'If provided, sends a test email to this address instead of all subscribers.',
|
|
23
|
+
},
|
|
24
|
+
subscriber_ids: {
|
|
25
|
+
type: 'array',
|
|
26
|
+
items: { type: 'string' },
|
|
27
|
+
description: 'Send only to these subscriber IDs (use list_subscribers to find IDs).',
|
|
28
|
+
},
|
|
29
|
+
exclude_issue_id: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Send to subscribers who did NOT receive this issue (use list_newsletter_issues to find issue IDs). For "send to remaining" after a partial send.',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
handler: async (params) => {
|
|
36
|
+
const { html, text, subject: docTitle, json } = await documentToEmail();
|
|
37
|
+
const subject = params.subject || docTitle;
|
|
38
|
+
const format = params.format || 'html';
|
|
39
|
+
const testEmail = params.test_email;
|
|
40
|
+
const subscriberIds = params.subscriber_ids;
|
|
41
|
+
const excludeIssueId = params.exclude_issue_id;
|
|
42
|
+
const server = await getServerModules();
|
|
43
|
+
const metadata = server.getMetadata();
|
|
44
|
+
const previewText = metadata?.newsletterContext?.previewText || undefined;
|
|
45
|
+
if (!text.trim() && !html.trim()) {
|
|
46
|
+
return { error: 'Newsletter body is empty. Write some content in the editor before sending.' };
|
|
47
|
+
}
|
|
48
|
+
const images = format === 'html' && html ? await extractLocalImages(html) : [];
|
|
49
|
+
const docId = server.getDocId();
|
|
50
|
+
const res = await publishFetch(config, '/newsletter/issues/send', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
subject,
|
|
54
|
+
content_html: format === 'html' ? html : null,
|
|
55
|
+
content_text: text,
|
|
56
|
+
content_json: json,
|
|
57
|
+
format,
|
|
58
|
+
test_email: testEmail,
|
|
59
|
+
preview_text: previewText,
|
|
60
|
+
images,
|
|
61
|
+
document_id: docId || undefined,
|
|
62
|
+
subscriber_ids: subscriberIds,
|
|
63
|
+
exclude_issue_id: excludeIssueId,
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const err = await res.json().catch(() => ({}));
|
|
68
|
+
return { error: `Send failed: ${err.error || res.statusText}` };
|
|
69
|
+
}
|
|
70
|
+
const data = await res.json();
|
|
71
|
+
if (data.test) {
|
|
72
|
+
return {
|
|
73
|
+
success: true,
|
|
74
|
+
test: true,
|
|
75
|
+
sent_to: data.sent_to,
|
|
76
|
+
message: `Test email sent to ${data.sent_to}.`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
success: true,
|
|
81
|
+
sent: data.sent,
|
|
82
|
+
failed: data.failed,
|
|
83
|
+
issueId: data.issueId,
|
|
84
|
+
message: `Newsletter sent to ${data.sent} subscribers.${data.failed && data.failed > 0 ? ` ${data.failed} failed.` : ''}`,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'list_subscribers',
|
|
90
|
+
description: 'List newsletter subscribers for the active profile.',
|
|
91
|
+
inputSchema: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
properties: {
|
|
94
|
+
limit: { type: 'number', description: 'Max subscribers to return (default 100)' },
|
|
95
|
+
offset: { type: 'number', description: 'Pagination offset (default 0)' },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
handler: async (params) => {
|
|
99
|
+
const limit = params.limit || 100;
|
|
100
|
+
const offset = params.offset || 0;
|
|
101
|
+
const res = await publishFetch(config, `/newsletter/subscribers?limit=${limit}&offset=${offset}`, {});
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
const err = await res.json().catch(() => ({}));
|
|
104
|
+
return { error: `Failed to list subscribers: ${err.error || res.statusText}` };
|
|
105
|
+
}
|
|
106
|
+
const data = await res.json();
|
|
107
|
+
const countRes = await publishFetch(config, '/newsletter/subscribers/count', {});
|
|
108
|
+
const countData = countRes.ok
|
|
109
|
+
? await countRes.json()
|
|
110
|
+
: { count: data.subscribers.length };
|
|
111
|
+
return {
|
|
112
|
+
count: countData.count,
|
|
113
|
+
subscribers: data.subscribers.map((s) => ({
|
|
114
|
+
id: s.id,
|
|
115
|
+
email: s.email,
|
|
116
|
+
name: s.name,
|
|
117
|
+
subscribedAt: s.subscribed_at,
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'add_subscriber',
|
|
124
|
+
description: 'Add a subscriber to the newsletter for the active profile.',
|
|
125
|
+
inputSchema: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
email: { type: 'string', description: 'Subscriber email address' },
|
|
129
|
+
name: { type: 'string', description: 'Subscriber name (optional)' },
|
|
130
|
+
},
|
|
131
|
+
required: ['email'],
|
|
132
|
+
},
|
|
133
|
+
handler: async (params) => {
|
|
134
|
+
const res = await publishFetch(config, '/newsletter/subscribers', {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
email: params.email,
|
|
138
|
+
name: params.name || undefined,
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
const err = await res.json().catch(() => ({}));
|
|
143
|
+
return { error: `Failed to add subscriber: ${err.error || res.statusText}` };
|
|
144
|
+
}
|
|
145
|
+
const data = await res.json();
|
|
146
|
+
return {
|
|
147
|
+
success: true,
|
|
148
|
+
subscriber: data.subscriber,
|
|
149
|
+
message: `Added ${data.subscriber.email} to newsletter.`,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'import_subscribers',
|
|
155
|
+
description: 'Bulk import subscribers from a CSV file or pasted CSV text. Supports common export formats from ConvertKit, Mailchimp, Substack, Beehiiv, etc. Auto-detects column names (email/Email Address/email_address, name/first_name+last_name). Skips invalid emails and unsubscribed rows.',
|
|
156
|
+
inputSchema: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
properties: {
|
|
159
|
+
file: {
|
|
160
|
+
type: 'string',
|
|
161
|
+
description: 'Absolute path to a CSV file (e.g. "C:/Users/me/Downloads/subscribers.csv")',
|
|
162
|
+
},
|
|
163
|
+
csv_text: {
|
|
164
|
+
type: 'string',
|
|
165
|
+
description: 'Raw CSV text pasted directly (header row + data rows)',
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
handler: async (params) => {
|
|
170
|
+
const filePath = params.file;
|
|
171
|
+
const csvText = params.csv_text;
|
|
172
|
+
if (!filePath && !csvText) {
|
|
173
|
+
return { error: 'Provide either file (path to CSV) or csv_text (raw CSV content)' };
|
|
174
|
+
}
|
|
175
|
+
let raw;
|
|
176
|
+
if (filePath) {
|
|
177
|
+
try {
|
|
178
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
return { error: `Failed to read file: ${e.message}` };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
raw = csvText;
|
|
186
|
+
}
|
|
187
|
+
// Parse CSV
|
|
188
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.trim());
|
|
189
|
+
if (lines.length < 2) {
|
|
190
|
+
return { error: 'CSV must have a header row and at least one data row' };
|
|
191
|
+
}
|
|
192
|
+
const parseCsvLine = (line) => {
|
|
193
|
+
const fields = [];
|
|
194
|
+
let current = '';
|
|
195
|
+
let inQuotes = false;
|
|
196
|
+
for (let i = 0; i < line.length; i++) {
|
|
197
|
+
const ch = line[i];
|
|
198
|
+
if (ch === '"') {
|
|
199
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
200
|
+
current += '"';
|
|
201
|
+
i++;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
inQuotes = !inQuotes;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else if (ch === ',' && !inQuotes) {
|
|
208
|
+
fields.push(current.trim());
|
|
209
|
+
current = '';
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
current += ch;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
fields.push(current.trim());
|
|
216
|
+
return fields;
|
|
217
|
+
};
|
|
218
|
+
const headers = parseCsvLine(lines[0]).map((h) => h.toLowerCase().replace(/['"]/g, '').trim());
|
|
219
|
+
const emailAliases = ['email', 'email_address', 'email address', 'subscriber_email', 'e-mail', 'contact email'];
|
|
220
|
+
const nameAliases = ['name', 'full_name', 'full name', 'subscriber_name'];
|
|
221
|
+
const firstNameAliases = ['first_name', 'first name', 'firstname', 'first'];
|
|
222
|
+
const lastNameAliases = ['last_name', 'last name', 'lastname', 'last'];
|
|
223
|
+
const statusAliases = ['status', 'state', 'subscription_status'];
|
|
224
|
+
const findCol = (aliases) => headers.findIndex((h) => aliases.includes(h));
|
|
225
|
+
const emailIdx = findCol(emailAliases);
|
|
226
|
+
const nameIdx = findCol(nameAliases);
|
|
227
|
+
const firstIdx = findCol(firstNameAliases);
|
|
228
|
+
const lastIdx = findCol(lastNameAliases);
|
|
229
|
+
const statusIdx = findCol(statusAliases);
|
|
230
|
+
if (emailIdx === -1) {
|
|
231
|
+
return { error: `Could not find email column. Found headers: ${headers.join(', ')}` };
|
|
232
|
+
}
|
|
233
|
+
const skipStatuses = new Set(['unsubscribed', 'cancelled', 'canceled', 'inactive', 'bounced', 'complained']);
|
|
234
|
+
const subscribers = [];
|
|
235
|
+
let skippedStatus = 0;
|
|
236
|
+
for (let i = 1; i < lines.length; i++) {
|
|
237
|
+
const fields = parseCsvLine(lines[i]);
|
|
238
|
+
const email = (fields[emailIdx] || '').replace(/['"]/g, '').trim();
|
|
239
|
+
if (!email)
|
|
240
|
+
continue;
|
|
241
|
+
if (statusIdx !== -1) {
|
|
242
|
+
const status = (fields[statusIdx] || '').replace(/['"]/g, '').trim().toLowerCase();
|
|
243
|
+
if (skipStatuses.has(status)) {
|
|
244
|
+
skippedStatus++;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
let name;
|
|
249
|
+
if (nameIdx !== -1) {
|
|
250
|
+
name = (fields[nameIdx] || '').replace(/['"]/g, '').trim() || undefined;
|
|
251
|
+
}
|
|
252
|
+
else if (firstIdx !== -1) {
|
|
253
|
+
const first = (fields[firstIdx] || '').replace(/['"]/g, '').trim();
|
|
254
|
+
const last = lastIdx !== -1 ? (fields[lastIdx] || '').replace(/['"]/g, '').trim() : '';
|
|
255
|
+
name = [first, last].filter(Boolean).join(' ') || undefined;
|
|
256
|
+
}
|
|
257
|
+
subscribers.push({ email, ...(name ? { name } : {}) });
|
|
258
|
+
}
|
|
259
|
+
if (subscribers.length === 0) {
|
|
260
|
+
return { error: `No valid subscribers found. ${skippedStatus} skipped (unsubscribed/inactive).` };
|
|
261
|
+
}
|
|
262
|
+
const res = await publishFetch(config, '/newsletter/subscribers/import', {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
body: JSON.stringify({ subscribers }),
|
|
265
|
+
});
|
|
266
|
+
if (!res.ok) {
|
|
267
|
+
const err = await res.json().catch(() => ({}));
|
|
268
|
+
return { error: `Import failed: ${err.error || res.statusText}` };
|
|
269
|
+
}
|
|
270
|
+
const data = (await res.json());
|
|
271
|
+
return {
|
|
272
|
+
success: true,
|
|
273
|
+
imported: data.imported,
|
|
274
|
+
skippedInvalid: data.skipped,
|
|
275
|
+
skippedStatus,
|
|
276
|
+
total: subscribers.length + skippedStatus,
|
|
277
|
+
message: `Imported ${data.imported} subscribers.${skippedStatus ? ` ${skippedStatus} skipped (unsubscribed/inactive).` : ''}${data.skipped ? ` ${data.skipped} skipped (invalid email).` : ''}`,
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'resend_to_unopened',
|
|
283
|
+
description: 'Resend a newsletter issue to subscribers who didn\'t open it. Uses a new subject line but the same email body. Can only be done once per issue. Best practice: wait 48-72 hours after original send.',
|
|
284
|
+
inputSchema: {
|
|
285
|
+
type: 'object',
|
|
286
|
+
properties: {
|
|
287
|
+
issue_id: { type: 'string', description: 'Newsletter issue ID to resend (from list_newsletter_issues)' },
|
|
288
|
+
subject: { type: 'string', description: 'New subject line for the resend (tip: try a different angle)' },
|
|
289
|
+
},
|
|
290
|
+
required: ['issue_id', 'subject'],
|
|
291
|
+
},
|
|
292
|
+
handler: async (params) => {
|
|
293
|
+
const issueId = params.issue_id;
|
|
294
|
+
const subject = params.subject;
|
|
295
|
+
const res = await publishFetch(config, `/newsletter/issues/${issueId}/resend`, {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
body: JSON.stringify({ subject }),
|
|
298
|
+
});
|
|
299
|
+
if (!res.ok) {
|
|
300
|
+
const err = await res.json().catch(() => ({}));
|
|
301
|
+
return { error: `Resend failed: ${err.error || res.statusText}` };
|
|
302
|
+
}
|
|
303
|
+
const data = await res.json();
|
|
304
|
+
return {
|
|
305
|
+
success: true,
|
|
306
|
+
sent: data.sent,
|
|
307
|
+
failed: data.failed,
|
|
308
|
+
non_openers: data.non_openers,
|
|
309
|
+
message: `Resent to ${data.sent} non-openers with new subject: "${subject}".${data.failed > 0 ? ` ${data.failed} failed.` : ''}`,
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
name: 'list_newsletter_issues',
|
|
315
|
+
description: 'List past newsletter sends with open/click stats. Returns issue IDs needed for get_newsletter_analytics and send_newsletter exclude_issue_id.',
|
|
316
|
+
inputSchema: {
|
|
317
|
+
type: 'object',
|
|
318
|
+
properties: {
|
|
319
|
+
limit: { type: 'number', description: 'Max issues to return (default 20, max 200)' },
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
handler: async (params) => {
|
|
323
|
+
const limit = Math.min(params.limit || 20, 200);
|
|
324
|
+
const res = await publishFetch(config, `/newsletter/issues?limit=${limit}`);
|
|
325
|
+
if (!res.ok) {
|
|
326
|
+
const err = await res.json().catch(() => ({}));
|
|
327
|
+
return { error: `Failed to list issues: ${err.error || res.statusText}` };
|
|
328
|
+
}
|
|
329
|
+
const data = await res.json();
|
|
330
|
+
return {
|
|
331
|
+
issues: data.issues.map((i) => ({
|
|
332
|
+
id: i.id,
|
|
333
|
+
subject: i.subject,
|
|
334
|
+
status: i.status,
|
|
335
|
+
sent_at: i.sent_at,
|
|
336
|
+
recipient_count: i.recipient_count,
|
|
337
|
+
unique_opens: i.unique_opens,
|
|
338
|
+
unique_clicks: i.unique_clicks,
|
|
339
|
+
})),
|
|
340
|
+
};
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: 'get_newsletter_analytics',
|
|
345
|
+
description: 'Get detailed analytics for a specific newsletter issue. Shows delivery stats, per-subscriber event breakdown, and recipient list.',
|
|
346
|
+
inputSchema: {
|
|
347
|
+
type: 'object',
|
|
348
|
+
properties: {
|
|
349
|
+
issue_id: { type: 'string', description: 'Newsletter issue ID (from list_newsletter_issues)' },
|
|
350
|
+
},
|
|
351
|
+
required: ['issue_id'],
|
|
352
|
+
},
|
|
353
|
+
handler: async (params) => {
|
|
354
|
+
const issueId = params.issue_id;
|
|
355
|
+
const res = await publishFetch(config, `/newsletter/issues/${issueId}/analytics`);
|
|
356
|
+
if (!res.ok) {
|
|
357
|
+
const err = await res.json().catch(() => ({}));
|
|
358
|
+
return { error: `Failed to get analytics: ${err.error || res.statusText}` };
|
|
359
|
+
}
|
|
360
|
+
return await res.json();
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
];
|
|
364
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openwriter/plugin-publish",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenWriter Publish — newsletter, custom domains, publishing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"markdown-it": "^14.1.0",
|
|
13
|
+
"markdown-it-ins": "^4.0.0",
|
|
14
|
+
"markdown-it-mark": "^4.0.0",
|
|
15
|
+
"markdown-it-sub": "^2.0.0",
|
|
16
|
+
"markdown-it-sup": "^2.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/express": "^5.0.0",
|
|
20
|
+
"@types/markdown-it": "^14.1.2",
|
|
21
|
+
"typescript": "^5.6.0"
|
|
22
|
+
},
|
|
23
|
+
"openwriter": {
|
|
24
|
+
"displayName": "Publish",
|
|
25
|
+
"category": "publishing"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist/",
|
|
29
|
+
"package.json"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X API plugin for OpenWriter.
|
|
3
|
+
* Registers routes for checking X connection status and posting tweets.
|
|
4
|
+
* Uses @xdevplatform/xdk with OAuth1 credentials from plugin config.
|
|
5
|
+
*/
|
|
6
|
+
import type { Express } from 'express';
|
|
7
|
+
interface PluginConfigField {
|
|
8
|
+
type: 'string' | 'number' | 'boolean';
|
|
9
|
+
required?: boolean;
|
|
10
|
+
env?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
interface PluginRouteContext {
|
|
14
|
+
app: Express;
|
|
15
|
+
config: Record<string, string>;
|
|
16
|
+
dataDir: string;
|
|
17
|
+
}
|
|
18
|
+
interface OpenWriterPlugin {
|
|
19
|
+
name: string;
|
|
20
|
+
version: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
category?: 'writing' | 'social-media' | 'image-generation';
|
|
23
|
+
configSchema?: Record<string, PluginConfigField>;
|
|
24
|
+
registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
declare const plugin: OpenWriterPlugin;
|
|
27
|
+
export default plugin;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X API plugin for OpenWriter.
|
|
3
|
+
* Registers routes for checking X connection status and posting tweets.
|
|
4
|
+
* Uses @xdevplatform/xdk with OAuth1 credentials from plugin config.
|
|
5
|
+
*/
|
|
6
|
+
import { Client, OAuth1 } from '@xdevplatform/xdk';
|
|
7
|
+
import { join, extname } from 'path';
|
|
8
|
+
import { readFileSync, existsSync } from 'fs';
|
|
9
|
+
function createXClient(config) {
|
|
10
|
+
const apiKey = config['api-key'] || process.env.X_API_KEY || '';
|
|
11
|
+
const apiSecret = config['api-secret'] || process.env.X_API_SECRET || '';
|
|
12
|
+
const accessToken = config['access-token'] || process.env.X_ACCESS_TOKEN || '';
|
|
13
|
+
const accessTokenSecret = config['access-token-secret'] || process.env.X_ACCESS_TOKEN_SECRET || '';
|
|
14
|
+
if (!apiKey || !apiSecret || !accessToken || !accessTokenSecret)
|
|
15
|
+
return null;
|
|
16
|
+
const oauth1 = new OAuth1({
|
|
17
|
+
apiKey,
|
|
18
|
+
apiSecret,
|
|
19
|
+
callback: 'oob',
|
|
20
|
+
accessToken,
|
|
21
|
+
accessTokenSecret,
|
|
22
|
+
});
|
|
23
|
+
return new Client({ oauth1 });
|
|
24
|
+
}
|
|
25
|
+
const plugin = {
|
|
26
|
+
name: '@openwriter/plugin-x-api',
|
|
27
|
+
version: '0.1.0',
|
|
28
|
+
description: 'Post tweets from OpenWriter',
|
|
29
|
+
category: 'social-media',
|
|
30
|
+
configSchema: {
|
|
31
|
+
'api-key': { type: 'string', env: 'X_API_KEY', description: 'X API Key' },
|
|
32
|
+
'api-secret': { type: 'string', env: 'X_API_SECRET', description: 'X API Secret' },
|
|
33
|
+
'access-token': { type: 'string', env: 'X_ACCESS_TOKEN', description: 'X Access Token' },
|
|
34
|
+
'access-token-secret': { type: 'string', env: 'X_ACCESS_TOKEN_SECRET', description: 'X Access Token Secret' },
|
|
35
|
+
},
|
|
36
|
+
registerRoutes(ctx) {
|
|
37
|
+
// GET /api/x/status — check if plugin is configured + authenticated
|
|
38
|
+
ctx.app.get('/api/x/status', async (_req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const client = createXClient(ctx.config);
|
|
41
|
+
if (!client) {
|
|
42
|
+
res.json({ connected: false });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const me = await client.users.getMe();
|
|
46
|
+
const username = me?.data?.username;
|
|
47
|
+
res.json({ connected: true, username: username || undefined });
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
console.error('[X Plugin] Status check failed:', err.message);
|
|
51
|
+
res.json({ connected: false, error: err.message });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
// POST /api/x/post — post a tweet (with optional media)
|
|
55
|
+
ctx.app.post('/api/x/post', async (req, res) => {
|
|
56
|
+
try {
|
|
57
|
+
const { text, replyTo, quoteTweetId, mediaIds } = req.body;
|
|
58
|
+
if ((!text || typeof text !== 'string') && (!Array.isArray(mediaIds) || mediaIds.length === 0)) {
|
|
59
|
+
res.status(400).json({ success: false, error: 'text or mediaIds is required' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (mediaIds && (!Array.isArray(mediaIds) || mediaIds.length > 4)) {
|
|
63
|
+
res.status(400).json({ success: false, error: 'mediaIds must be an array of 1-4 IDs' });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const client = createXClient(ctx.config);
|
|
67
|
+
if (!client) {
|
|
68
|
+
res.status(400).json({ success: false, error: 'X API credentials not configured' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const body = {};
|
|
72
|
+
if (text)
|
|
73
|
+
body.text = text;
|
|
74
|
+
if (replyTo) {
|
|
75
|
+
body.reply = { inReplyToTweetId: replyTo };
|
|
76
|
+
}
|
|
77
|
+
if (quoteTweetId) {
|
|
78
|
+
body.quoteTweetId = quoteTweetId;
|
|
79
|
+
}
|
|
80
|
+
if (mediaIds && mediaIds.length > 0) {
|
|
81
|
+
body.media = { media_ids: mediaIds };
|
|
82
|
+
}
|
|
83
|
+
const result = await client.posts.create(body);
|
|
84
|
+
const tweetId = result?.data?.id;
|
|
85
|
+
const tweetUrl = tweetId ? `https://x.com/i/status/${tweetId}` : undefined;
|
|
86
|
+
res.json({ success: true, tweetId, tweetUrl });
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
const detail = err.data ? JSON.stringify(err.data) : err.message;
|
|
90
|
+
console.error('[X Plugin] Post failed:', detail);
|
|
91
|
+
res.status(500).json({ success: false, error: detail });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
// POST /api/x/post-thread — post a full thread as a reply chain (with optional media per tweet)
|
|
95
|
+
ctx.app.post('/api/x/post-thread', async (req, res) => {
|
|
96
|
+
try {
|
|
97
|
+
const { tweets, replyTo } = req.body;
|
|
98
|
+
if (!Array.isArray(tweets) || tweets.length === 0) {
|
|
99
|
+
res.status(400).json({ success: false, error: 'tweets must be a non-empty array' });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Normalize: accept string[] or { text, mediaIds? }[]
|
|
103
|
+
const normalized = tweets.map((t) => typeof t === 'string' ? { text: t, mediaIds: undefined } : t);
|
|
104
|
+
// Validate character limits (X API v2 supports up to 25k chars for Premium accounts)
|
|
105
|
+
const CHAR_LIMIT = 25000;
|
|
106
|
+
const overLimit = normalized.map((t, i) => ({ i, len: t.text.length })).filter(x => x.len > CHAR_LIMIT);
|
|
107
|
+
if (overLimit.length > 0) {
|
|
108
|
+
res.status(400).json({
|
|
109
|
+
success: false,
|
|
110
|
+
error: `${overLimit.length} tweet(s) exceed ${CHAR_LIMIT} chars: ${overLimit.map(x => `#${x.i + 1} (${x.len})`).join(', ')}`,
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Validate mediaIds per tweet
|
|
115
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
116
|
+
const ids = normalized[i].mediaIds;
|
|
117
|
+
if (ids && (!Array.isArray(ids) || ids.length > 4)) {
|
|
118
|
+
res.status(400).json({ success: false, error: `Tweet ${i + 1}: mediaIds must be an array of 1-4 IDs` });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const client = createXClient(ctx.config);
|
|
123
|
+
if (!client) {
|
|
124
|
+
res.status(400).json({ success: false, error: 'X API credentials not configured' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const postedTweets = [];
|
|
128
|
+
let previousTweetId = replyTo;
|
|
129
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
130
|
+
const { text, mediaIds } = normalized[i];
|
|
131
|
+
const body = {};
|
|
132
|
+
if (text)
|
|
133
|
+
body.text = text;
|
|
134
|
+
if (previousTweetId) {
|
|
135
|
+
body.reply = { inReplyToTweetId: previousTweetId };
|
|
136
|
+
}
|
|
137
|
+
if (mediaIds && mediaIds.length > 0) {
|
|
138
|
+
body.media = { media_ids: mediaIds };
|
|
139
|
+
}
|
|
140
|
+
const result = await client.posts.create(body);
|
|
141
|
+
const tweetId = result?.data?.id;
|
|
142
|
+
if (!tweetId) {
|
|
143
|
+
res.status(500).json({
|
|
144
|
+
success: false,
|
|
145
|
+
postedTweets,
|
|
146
|
+
failedAt: i,
|
|
147
|
+
error: `Tweet ${i + 1} posted but no ID returned`,
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
postedTweets.push({ index: i, tweetId, text });
|
|
152
|
+
previousTweetId = tweetId;
|
|
153
|
+
}
|
|
154
|
+
// Build thread URL from first tweet
|
|
155
|
+
const firstTweetId = postedTweets[0]?.tweetId;
|
|
156
|
+
const threadUrl = firstTweetId ? `https://x.com/i/status/${firstTweetId}` : undefined;
|
|
157
|
+
console.log(`[X Plugin] Thread posted: ${postedTweets.length} tweets, ${threadUrl}`);
|
|
158
|
+
res.json({ success: true, postedTweets, threadUrl });
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
console.error('[X Plugin] Post thread failed:', err.message);
|
|
162
|
+
res.status(500).json({ success: false, error: err.message });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
// POST /api/x/upload-media — upload a local /_images/ file for tweet attachment
|
|
166
|
+
ctx.app.post('/api/x/upload-media', async (req, res) => {
|
|
167
|
+
try {
|
|
168
|
+
const { src } = req.body;
|
|
169
|
+
if (!src || typeof src !== 'string') {
|
|
170
|
+
res.status(400).json({ success: false, error: 'src is required' });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Security: only allow /_images/ paths, no traversal
|
|
174
|
+
if (!/^\/_images\/[^/\\]+$/.test(src)) {
|
|
175
|
+
res.status(400).json({ success: false, error: 'Invalid image path — must be /_images/<filename>' });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const filename = src.replace('/_images/', '');
|
|
179
|
+
const filePath = join(ctx.dataDir, '_images', filename);
|
|
180
|
+
if (!existsSync(filePath)) {
|
|
181
|
+
res.status(404).json({ success: false, error: `Image not found: ${filename}` });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const ext = extname(filename).toLowerCase();
|
|
185
|
+
const mimeMap = {
|
|
186
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
187
|
+
'.png': 'image/png', '.webp': 'image/webp',
|
|
188
|
+
'.gif': 'image/jpeg', '.bmp': 'image/bmp',
|
|
189
|
+
'.tiff': 'image/tiff', '.tif': 'image/tiff',
|
|
190
|
+
};
|
|
191
|
+
const mediaType = mimeMap[ext] || 'image/jpeg';
|
|
192
|
+
const client = createXClient(ctx.config);
|
|
193
|
+
if (!client) {
|
|
194
|
+
res.status(400).json({ success: false, error: 'X API credentials not configured' });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const mediaBase64 = readFileSync(filePath).toString('base64');
|
|
198
|
+
const uploadResult = await client.media.upload({
|
|
199
|
+
body: { media: mediaBase64, mediaCategory: 'tweet_image', mediaType },
|
|
200
|
+
});
|
|
201
|
+
const mediaId = uploadResult?.data?.id
|
|
202
|
+
|| uploadResult?.media_id_string;
|
|
203
|
+
if (!mediaId) {
|
|
204
|
+
res.status(500).json({ success: false, error: 'Upload succeeded but no media ID returned' });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
console.log(`[X Plugin] Media uploaded: ${filename} → ${mediaId}`);
|
|
208
|
+
res.json({ success: true, mediaId });
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
console.error('[X Plugin] Media upload failed:', err.message);
|
|
212
|
+
res.status(500).json({ success: false, error: err.message });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
export default plugin;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openwriter/plugin-x-api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Post tweets from OpenWriter",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@xdevplatform/xdk": "^0.4.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/express": "^5.0.0",
|
|
16
|
+
"typescript": "^5.6.0"
|
|
17
|
+
},
|
|
18
|
+
"openwriter": {
|
|
19
|
+
"displayName": "X / Twitter",
|
|
20
|
+
"category": "social-media"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist/",
|
|
24
|
+
"package.json"
|
|
25
|
+
]
|
|
26
|
+
}
|