openwriter 0.11.0 → 0.12.1

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.
@@ -1,240 +0,0 @@
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
- import sharp from 'sharp';
10
- import twitter from 'twitter-text';
11
- const { parseTweet } = twitter;
12
- function createXClient(config) {
13
- const apiKey = config['api-key'] || process.env.X_API_KEY || '';
14
- const apiSecret = config['api-secret'] || process.env.X_API_SECRET || '';
15
- const accessToken = config['access-token'] || process.env.X_ACCESS_TOKEN || '';
16
- const accessTokenSecret = config['access-token-secret'] || process.env.X_ACCESS_TOKEN_SECRET || '';
17
- if (!apiKey || !apiSecret || !accessToken || !accessTokenSecret)
18
- return null;
19
- const oauth1 = new OAuth1({
20
- apiKey,
21
- apiSecret,
22
- callback: 'oob',
23
- accessToken,
24
- accessTokenSecret,
25
- });
26
- return new Client({ oauth1 });
27
- }
28
- const plugin = {
29
- name: '@openwriter/plugin-x-api',
30
- version: '0.1.0',
31
- description: 'Post tweets from OpenWriter',
32
- category: 'social-media',
33
- configSchema: {
34
- 'api-key': { type: 'string', env: 'X_API_KEY', description: 'X API Key' },
35
- 'api-secret': { type: 'string', env: 'X_API_SECRET', description: 'X API Secret' },
36
- 'access-token': { type: 'string', env: 'X_ACCESS_TOKEN', description: 'X Access Token' },
37
- 'access-token-secret': { type: 'string', env: 'X_ACCESS_TOKEN_SECRET', description: 'X Access Token Secret' },
38
- },
39
- registerRoutes(ctx) {
40
- // GET /api/x/status — check if plugin is configured + authenticated
41
- ctx.app.get('/api/x/status', async (_req, res) => {
42
- try {
43
- const client = createXClient(ctx.config);
44
- if (!client) {
45
- res.json({ connected: false });
46
- return;
47
- }
48
- const me = await client.users.getMe();
49
- const username = me?.data?.username;
50
- res.json({ connected: true, username: username || undefined });
51
- }
52
- catch (err) {
53
- console.error('[X Plugin] Status check failed:', err.message);
54
- res.json({ connected: false, error: err.message });
55
- }
56
- });
57
- // POST /api/x/post — post a tweet (with optional media)
58
- ctx.app.post('/api/x/post', async (req, res) => {
59
- try {
60
- const { text, replyTo, quoteTweetId, mediaIds } = req.body;
61
- if ((!text || typeof text !== 'string') && (!Array.isArray(mediaIds) || mediaIds.length === 0)) {
62
- res.status(400).json({ success: false, error: 'text or mediaIds is required' });
63
- return;
64
- }
65
- if (mediaIds && (!Array.isArray(mediaIds) || mediaIds.length > 4)) {
66
- res.status(400).json({ success: false, error: 'mediaIds must be an array of 1-4 IDs' });
67
- return;
68
- }
69
- const client = createXClient(ctx.config);
70
- if (!client) {
71
- res.status(400).json({ success: false, error: 'X API credentials not configured' });
72
- return;
73
- }
74
- const body = {};
75
- if (text)
76
- body.text = text;
77
- if (replyTo) {
78
- body.reply = { inReplyToTweetId: replyTo };
79
- }
80
- if (quoteTweetId) {
81
- body.quoteTweetId = quoteTweetId;
82
- }
83
- if (mediaIds && mediaIds.length > 0) {
84
- body.media = { media_ids: mediaIds };
85
- }
86
- const result = await client.posts.create(body);
87
- const tweetId = result?.data?.id;
88
- const tweetUrl = tweetId ? `https://x.com/i/status/${tweetId}` : undefined;
89
- res.json({ success: true, tweetId, tweetUrl });
90
- }
91
- catch (err) {
92
- const detail = err.data ? JSON.stringify(err.data) : err.message;
93
- console.error('[X Plugin] Post failed:', detail);
94
- res.status(500).json({ success: false, error: detail });
95
- }
96
- });
97
- // POST /api/x/post-thread — post a full thread as a reply chain (with optional media per tweet)
98
- ctx.app.post('/api/x/post-thread', async (req, res) => {
99
- try {
100
- const { tweets, replyTo } = req.body;
101
- if (!Array.isArray(tweets) || tweets.length === 0) {
102
- res.status(400).json({ success: false, error: 'tweets must be a non-empty array' });
103
- return;
104
- }
105
- // Normalize: accept string[] or { text, mediaIds? }[]
106
- const normalized = tweets.map((t) => typeof t === 'string' ? { text: t, mediaIds: undefined } : t);
107
- // Validate character limits using X's weighted counting (emojis=2, URLs=23, CJK=2)
108
- const CHAR_LIMIT = 25000;
109
- const overLimit = normalized.map((t, i) => ({ i, len: parseTweet(t.text).weightedLength })).filter(x => x.len > CHAR_LIMIT);
110
- if (overLimit.length > 0) {
111
- res.status(400).json({
112
- success: false,
113
- error: `${overLimit.length} tweet(s) exceed ${CHAR_LIMIT} chars: ${overLimit.map(x => `#${x.i + 1} (${x.len})`).join(', ')}`,
114
- });
115
- return;
116
- }
117
- // Validate mediaIds per tweet
118
- for (let i = 0; i < normalized.length; i++) {
119
- const ids = normalized[i].mediaIds;
120
- if (ids && (!Array.isArray(ids) || ids.length > 4)) {
121
- res.status(400).json({ success: false, error: `Tweet ${i + 1}: mediaIds must be an array of 1-4 IDs` });
122
- return;
123
- }
124
- }
125
- const client = createXClient(ctx.config);
126
- if (!client) {
127
- res.status(400).json({ success: false, error: 'X API credentials not configured' });
128
- return;
129
- }
130
- const postedTweets = [];
131
- let previousTweetId = replyTo;
132
- for (let i = 0; i < normalized.length; i++) {
133
- const { text, mediaIds } = normalized[i];
134
- const body = {};
135
- if (text)
136
- body.text = text;
137
- if (previousTweetId) {
138
- body.reply = { inReplyToTweetId: previousTweetId };
139
- }
140
- if (mediaIds && mediaIds.length > 0) {
141
- body.media = { media_ids: mediaIds };
142
- }
143
- const result = await client.posts.create(body);
144
- const tweetId = result?.data?.id;
145
- if (!tweetId) {
146
- res.status(500).json({
147
- success: false,
148
- postedTweets,
149
- failedAt: i,
150
- error: `Tweet ${i + 1} posted but no ID returned`,
151
- });
152
- return;
153
- }
154
- postedTweets.push({ index: i, tweetId, text });
155
- previousTweetId = tweetId;
156
- }
157
- // Build thread URL from first tweet
158
- const firstTweetId = postedTweets[0]?.tweetId;
159
- const threadUrl = firstTweetId ? `https://x.com/i/status/${firstTweetId}` : undefined;
160
- console.log(`[X Plugin] Thread posted: ${postedTweets.length} tweets, ${threadUrl}`);
161
- res.json({ success: true, postedTweets, threadUrl });
162
- }
163
- catch (err) {
164
- console.error('[X Plugin] Post thread failed:', err.message);
165
- res.status(500).json({ success: false, error: err.message });
166
- }
167
- });
168
- // POST /api/x/upload-media — upload a local /_images/ file for tweet attachment
169
- ctx.app.post('/api/x/upload-media', async (req, res) => {
170
- try {
171
- const { src } = req.body;
172
- if (!src || typeof src !== 'string') {
173
- res.status(400).json({ success: false, error: 'src is required' });
174
- return;
175
- }
176
- // Security: only allow /_images/ paths, no traversal
177
- if (!/^\/_images\/[^/\\]+$/.test(src)) {
178
- res.status(400).json({ success: false, error: 'Invalid image path — must be /_images/<filename>' });
179
- return;
180
- }
181
- const filename = src.replace('/_images/', '');
182
- const filePath = join(ctx.dataDir, '_images', filename);
183
- if (!existsSync(filePath)) {
184
- res.status(404).json({ success: false, error: `Image not found: ${filename}` });
185
- return;
186
- }
187
- const ext = extname(filename).toLowerCase();
188
- const mimeMap = {
189
- '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
190
- '.png': 'image/png', '.webp': 'image/webp',
191
- '.gif': 'image/jpeg', '.bmp': 'image/bmp',
192
- '.tiff': 'image/tiff', '.tif': 'image/tiff',
193
- };
194
- const mediaType = mimeMap[ext] || 'image/jpeg';
195
- const client = createXClient(ctx.config);
196
- if (!client) {
197
- res.status(400).json({ success: false, error: 'X API credentials not configured' });
198
- return;
199
- }
200
- let fileBuffer = readFileSync(filePath);
201
- let uploadType = mediaType;
202
- const origSize = fileBuffer.length;
203
- // Compress large images or PNGs to JPEG to stay under X API limits
204
- if (fileBuffer.length > 3 * 1024 * 1024 || ext === '.png') {
205
- fileBuffer = Buffer.from(await sharp(fileBuffer).jpeg({ quality: 85 }).toBuffer());
206
- uploadType = 'image/jpeg';
207
- console.log(`[X Plugin] Compressed ${filename}: ${(origSize / 1024 / 1024).toFixed(2)}MB → ${(fileBuffer.length / 1024 / 1024).toFixed(2)}MB`);
208
- }
209
- console.log(`[X Plugin] Uploading ${filename}: ${(fileBuffer.length / 1024 / 1024).toFixed(2)}MB, type: ${uploadType}`);
210
- const mediaBase64 = fileBuffer.toString('base64');
211
- const uploadResult = await client.media.upload({
212
- body: { media: mediaBase64, mediaCategory: 'tweet_image', mediaType: uploadType },
213
- });
214
- const mediaId = uploadResult?.data?.id
215
- || uploadResult?.media_id_string;
216
- if (!mediaId) {
217
- res.status(500).json({ success: false, error: 'Upload succeeded but no media ID returned' });
218
- return;
219
- }
220
- console.log(`[X Plugin] Media uploaded: ${filename} → ${mediaId}`);
221
- res.json({ success: true, mediaId });
222
- }
223
- catch (err) {
224
- console.error('[X Plugin] Media upload failed:', err.message);
225
- if (err.response) {
226
- try {
227
- const body = await err.response.text();
228
- console.error('[X Plugin] X API response:', err.response.status, body);
229
- }
230
- catch { /* ignore */ }
231
- }
232
- if (err.data)
233
- console.error('[X Plugin] Error data:', JSON.stringify(err.data));
234
- console.error('[X Plugin] Full error:', JSON.stringify(err, Object.getOwnPropertyNames(err)));
235
- res.status(500).json({ success: false, error: err.message });
236
- }
237
- });
238
- },
239
- };
240
- export default plugin;
@@ -1,27 +0,0 @@
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
- "twitter-text": "^3.1.0"
14
- },
15
- "devDependencies": {
16
- "@types/express": "^5.0.0",
17
- "typescript": "^5.6.0"
18
- },
19
- "openwriter": {
20
- "displayName": "X / Twitter",
21
- "category": "social-media"
22
- },
23
- "files": [
24
- "dist/",
25
- "package.json"
26
- ]
27
- }