@ldraney/mcp-linkedin 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 +21 -0
- package/README.md +128 -0
- package/package.json +50 -0
- package/src/auth-server.js +223 -0
- package/src/database.js +288 -0
- package/src/index.js +654 -0
- package/src/linkedin-api.js +631 -0
- package/src/scheduler.js +201 -0
- package/src/schemas.js +629 -0
- package/src/tools.js +853 -0
- package/src/types.js +575 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Lucas Draney
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# MCP LinkedIn
|
|
2
|
+
|
|
3
|
+
**Post to LinkedIn from Claude Desktop.**
|
|
4
|
+
|
|
5
|
+
Install in 30 seconds. Authenticate once. Post forever.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
### Option A: One-Click Install (Recommended)
|
|
10
|
+
1. **Download** [mcp-linkedin.mcpb](https://github.com/ldraney/mcp-linkedin/releases/latest)
|
|
11
|
+
2. **Install** in Claude Desktop: Settings → Extensions → Install Extension
|
|
12
|
+
3. **Authorize** when prompted (one-time LinkedIn OAuth)
|
|
13
|
+
|
|
14
|
+
### Option B: Command Line
|
|
15
|
+
```bash
|
|
16
|
+
npx @ldraney/mcp-linkedin
|
|
17
|
+
```
|
|
18
|
+
Then add to your Claude Desktop config and authorize.
|
|
19
|
+
|
|
20
|
+
That's it! Ask Claude to post to LinkedIn.
|
|
21
|
+
|
|
22
|
+
## What You Can Do
|
|
23
|
+
|
|
24
|
+
Ask Claude to:
|
|
25
|
+
|
|
26
|
+
- *"Post about my new GitHub project with a link"*
|
|
27
|
+
- *"Create a poll asking my network about AI trends"*
|
|
28
|
+
- *"Schedule a post for tomorrow at 9am"*
|
|
29
|
+
- *"Upload this PDF and share it with my network"*
|
|
30
|
+
|
|
31
|
+
### 20 Tools Available
|
|
32
|
+
|
|
33
|
+
| Category | Tools |
|
|
34
|
+
|----------|-------|
|
|
35
|
+
| **Posting** | Text, links, images, videos, documents, polls |
|
|
36
|
+
| **Multi-media** | Up to 20 images per post |
|
|
37
|
+
| **Scheduling** | Schedule posts for future publication |
|
|
38
|
+
| **Engagement** | Comment and react to posts |
|
|
39
|
+
| **Management** | Edit, delete, list your posts |
|
|
40
|
+
|
|
41
|
+
## How It Works
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
npm registry (@ldraney/mcp-linkedin)
|
|
45
|
+
│
|
|
46
|
+
├─► Developers: npx @ldraney/mcp-linkedin
|
|
47
|
+
│
|
|
48
|
+
└─► Everyone: Download .mcpb → Install via Extensions
|
|
49
|
+
│
|
|
50
|
+
└─► Both run the same npm package
|
|
51
|
+
│
|
|
52
|
+
└─► First use triggers OAuth
|
|
53
|
+
│
|
|
54
|
+
└─► Tokens stored locally
|
|
55
|
+
│
|
|
56
|
+
└─► Post to LinkedIn!
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Your credentials stay on your machine. Our backend only handles the OAuth handshake.
|
|
60
|
+
|
|
61
|
+
## Requirements
|
|
62
|
+
|
|
63
|
+
- [Claude Desktop](https://claude.ai/download) installed
|
|
64
|
+
- LinkedIn account
|
|
65
|
+
|
|
66
|
+
## Documentation
|
|
67
|
+
|
|
68
|
+
- [User Story](./docs/user-story.html) - Why this exists, user journey
|
|
69
|
+
- [Architecture](./docs/architecture.html) - Technical diagrams
|
|
70
|
+
- [Roadmap](./docs/roadmap.html) - What's coming next
|
|
71
|
+
|
|
72
|
+
## Features
|
|
73
|
+
|
|
74
|
+
### Core Posting
|
|
75
|
+
- **Text posts** with hashtags and mentions
|
|
76
|
+
- **Link posts** with article previews (great for GitHub links!)
|
|
77
|
+
- **Image posts** (PNG, JPG, GIF)
|
|
78
|
+
- **Multi-image posts** (2-20 images)
|
|
79
|
+
- **Video posts** (MP4, MOV, up to 200MB)
|
|
80
|
+
- **Document posts** (PDF, PPT, DOC)
|
|
81
|
+
- **Poll posts** (2-4 options, 1-14 day duration)
|
|
82
|
+
|
|
83
|
+
### Post Management
|
|
84
|
+
- Edit existing posts
|
|
85
|
+
- Delete posts
|
|
86
|
+
- List your recent posts with pagination
|
|
87
|
+
|
|
88
|
+
### Scheduling
|
|
89
|
+
LinkedIn's API doesn't support native scheduling, so we built it:
|
|
90
|
+
- Schedule posts for any future time
|
|
91
|
+
- List scheduled posts by status
|
|
92
|
+
- Cancel pending posts
|
|
93
|
+
|
|
94
|
+
### Social Interactions
|
|
95
|
+
- Add comments to posts
|
|
96
|
+
- React to posts (LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT)
|
|
97
|
+
|
|
98
|
+
## For Developers
|
|
99
|
+
|
|
100
|
+
Want to contribute or run locally?
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
git clone https://github.com/ldraney/mcp-linkedin.git
|
|
104
|
+
cd mcp-linkedin
|
|
105
|
+
npm install
|
|
106
|
+
npm test # 118 tests
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
See [CLAUDE.md](./CLAUDE.md) for development guidelines.
|
|
110
|
+
|
|
111
|
+
## Privacy
|
|
112
|
+
|
|
113
|
+
- Your LinkedIn access token is stored locally on your machine
|
|
114
|
+
- Our OAuth backend only facilitates the initial authentication
|
|
115
|
+
- We never store or have access to your LinkedIn credentials
|
|
116
|
+
- All API calls go directly from your machine to LinkedIn
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT - See [LICENSE](./LICENSE)
|
|
121
|
+
|
|
122
|
+
## Author
|
|
123
|
+
|
|
124
|
+
Lucas Draney ([@ldraney](https://github.com/ldraney))
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
**Questions?** Open an issue or reach out on LinkedIn!
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ldraney/mcp-linkedin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for LinkedIn automation - post, schedule, and engage from Claude Desktop",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-linkedin": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src/**/*.js",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/ldraney/mcp-linkedin.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/ldraney/mcp-linkedin",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"start": "node src/index.js",
|
|
24
|
+
"auth": "node src/auth-server.js",
|
|
25
|
+
"scheduler": "node src/scheduler.js",
|
|
26
|
+
"test": "jest",
|
|
27
|
+
"test:watch": "jest --watch",
|
|
28
|
+
"test:coverage": "jest --coverage",
|
|
29
|
+
"test:manual": "node test_post.js"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"mcp",
|
|
33
|
+
"linkedin",
|
|
34
|
+
"api",
|
|
35
|
+
"automation"
|
|
36
|
+
],
|
|
37
|
+
"author": "Lucas Draney",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
41
|
+
"better-sqlite3": "^11.6.0",
|
|
42
|
+
"dotenv": "^16.3.1",
|
|
43
|
+
"node-cron": "^3.0.3",
|
|
44
|
+
"uuid": "^11.0.3",
|
|
45
|
+
"zod": "^3.22.4"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"jest": "^29.7.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spike: Localhost OAuth Callback Server
|
|
3
|
+
*
|
|
4
|
+
* Validates that MCP can spin up a temporary HTTP server to receive
|
|
5
|
+
* OAuth callback from Fly.io backend.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node src/auth-server.js
|
|
9
|
+
*
|
|
10
|
+
* Then complete OAuth flow at:
|
|
11
|
+
* https://mcp-linkedin-oauth.fly.dev/auth/linkedin
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const url = require('url');
|
|
16
|
+
|
|
17
|
+
const PREFERRED_PORT = 3000;
|
|
18
|
+
const MAX_PORT_ATTEMPTS = 10;
|
|
19
|
+
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Start the OAuth callback server
|
|
23
|
+
* @returns {Promise<{code: string, port: number}>} Resolves with auth code when received
|
|
24
|
+
*/
|
|
25
|
+
function startAuthServer() {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
let timeoutId;
|
|
28
|
+
let server;
|
|
29
|
+
|
|
30
|
+
const handleCallback = (req, res) => {
|
|
31
|
+
const parsedUrl = url.parse(req.url, true);
|
|
32
|
+
|
|
33
|
+
// Only handle the callback path
|
|
34
|
+
if (parsedUrl.pathname !== '/oauth-callback') {
|
|
35
|
+
res.writeHead(404);
|
|
36
|
+
res.end('Not Found');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { code, error, error_description } = parsedUrl.query;
|
|
41
|
+
|
|
42
|
+
if (error) {
|
|
43
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
44
|
+
res.end(`
|
|
45
|
+
<html>
|
|
46
|
+
<head><title>Authentication Failed</title></head>
|
|
47
|
+
<body style="font-family: sans-serif; text-align: center; padding: 2rem;">
|
|
48
|
+
<h1 style="color: #dc2626;">Authentication Failed</h1>
|
|
49
|
+
<p>${error}: ${error_description || 'Unknown error'}</p>
|
|
50
|
+
<p>You can close this tab.</p>
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|
|
53
|
+
`);
|
|
54
|
+
cleanup();
|
|
55
|
+
reject(new Error(`OAuth error: ${error} - ${error_description}`));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!code) {
|
|
60
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
61
|
+
res.end(`
|
|
62
|
+
<html>
|
|
63
|
+
<head><title>Missing Code</title></head>
|
|
64
|
+
<body style="font-family: sans-serif; text-align: center; padding: 2rem;">
|
|
65
|
+
<h1 style="color: #dc2626;">Missing Authorization Code</h1>
|
|
66
|
+
<p>No code received from LinkedIn.</p>
|
|
67
|
+
<p>You can close this tab.</p>
|
|
68
|
+
</body>
|
|
69
|
+
</html>
|
|
70
|
+
`);
|
|
71
|
+
cleanup();
|
|
72
|
+
reject(new Error('No authorization code received'));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Success!
|
|
77
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
78
|
+
res.end(`
|
|
79
|
+
<html>
|
|
80
|
+
<head><title>Authentication Successful</title></head>
|
|
81
|
+
<body style="font-family: sans-serif; text-align: center; padding: 2rem;">
|
|
82
|
+
<h1 style="color: #10b981;">✓ Authentication Successful!</h1>
|
|
83
|
+
<p>You can close this tab and return to Claude.</p>
|
|
84
|
+
<script>setTimeout(() => window.close(), 3000);</script>
|
|
85
|
+
</body>
|
|
86
|
+
</html>
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
console.log(`[auth-server] Received authorization code: ${code.substring(0, 20)}...`);
|
|
90
|
+
|
|
91
|
+
cleanup();
|
|
92
|
+
resolve({ code, port: server.address().port });
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const cleanup = () => {
|
|
96
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
97
|
+
if (server) {
|
|
98
|
+
server.close(() => {
|
|
99
|
+
console.log('[auth-server] Server shut down');
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
server = http.createServer(handleCallback);
|
|
105
|
+
|
|
106
|
+
// Try to find an available port
|
|
107
|
+
const tryPort = (port, attempts = 0) => {
|
|
108
|
+
server.once('error', (err) => {
|
|
109
|
+
if (err.code === 'EADDRINUSE' && attempts < MAX_PORT_ATTEMPTS) {
|
|
110
|
+
console.log(`[auth-server] Port ${port} in use, trying ${port + 1}...`);
|
|
111
|
+
tryPort(port + 1, attempts + 1);
|
|
112
|
+
} else {
|
|
113
|
+
reject(err);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
server.listen(port, '127.0.0.1', () => {
|
|
118
|
+
const actualPort = server.address().port;
|
|
119
|
+
console.log(`[auth-server] Listening on http://localhost:${actualPort}/oauth-callback`);
|
|
120
|
+
console.log(`[auth-server] Waiting for OAuth callback (timeout: ${CALLBACK_TIMEOUT_MS / 1000}s)...`);
|
|
121
|
+
|
|
122
|
+
// Set timeout
|
|
123
|
+
timeoutId = setTimeout(() => {
|
|
124
|
+
console.log('[auth-server] Timeout waiting for callback');
|
|
125
|
+
cleanup();
|
|
126
|
+
reject(new Error('Timeout waiting for OAuth callback'));
|
|
127
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
tryPort(PREFERRED_PORT);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Exchange authorization code for access token
|
|
137
|
+
* This would normally call linkedin-api.js exchangeCode()
|
|
138
|
+
*/
|
|
139
|
+
async function exchangeCodeForToken(code) {
|
|
140
|
+
// For spike, just log that we would exchange
|
|
141
|
+
console.log(`[auth-server] Would exchange code for token: ${code.substring(0, 20)}...`);
|
|
142
|
+
|
|
143
|
+
// In real implementation:
|
|
144
|
+
// const { exchangeCode } = require('./linkedin-api');
|
|
145
|
+
// return exchangeCode(code);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
access_token: 'mock_token',
|
|
149
|
+
expires_in: 5184000,
|
|
150
|
+
refresh_token: 'mock_refresh'
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Store token to config directory
|
|
156
|
+
* This would normally save to ~/.config/mcp-linkedin/credentials.json
|
|
157
|
+
*/
|
|
158
|
+
async function storeToken(tokenData) {
|
|
159
|
+
const fs = require('fs').promises;
|
|
160
|
+
const path = require('path');
|
|
161
|
+
const os = require('os');
|
|
162
|
+
|
|
163
|
+
const configDir = path.join(os.homedir(), '.config', 'mcp-linkedin');
|
|
164
|
+
const credentialsPath = path.join(configDir, 'credentials.json');
|
|
165
|
+
|
|
166
|
+
// Create config directory if needed
|
|
167
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
168
|
+
|
|
169
|
+
// Store token with metadata
|
|
170
|
+
const credentials = {
|
|
171
|
+
...tokenData,
|
|
172
|
+
obtained_at: new Date().toISOString(),
|
|
173
|
+
expires_at: new Date(Date.now() + (tokenData.expires_in * 1000)).toISOString()
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
await fs.writeFile(credentialsPath, JSON.stringify(credentials, null, 2));
|
|
177
|
+
console.log(`[auth-server] Token stored at ${credentialsPath}`);
|
|
178
|
+
|
|
179
|
+
return credentialsPath;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Main execution
|
|
183
|
+
async function main() {
|
|
184
|
+
console.log('='.repeat(60));
|
|
185
|
+
console.log('MCP LinkedIn OAuth Callback Server (Spike)');
|
|
186
|
+
console.log('='.repeat(60));
|
|
187
|
+
console.log('');
|
|
188
|
+
console.log('1. Open this URL in your browser:');
|
|
189
|
+
console.log(' https://mcp-linkedin-oauth.fly.dev/auth/linkedin');
|
|
190
|
+
console.log('');
|
|
191
|
+
console.log('2. Authorize with LinkedIn');
|
|
192
|
+
console.log('');
|
|
193
|
+
console.log('3. You will be redirected back here');
|
|
194
|
+
console.log('');
|
|
195
|
+
console.log('='.repeat(60));
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const { code, port } = await startAuthServer();
|
|
199
|
+
console.log(`\n[auth-server] Success! Received code on port ${port}`);
|
|
200
|
+
|
|
201
|
+
// Exchange code for token
|
|
202
|
+
const tokenData = await exchangeCodeForToken(code);
|
|
203
|
+
console.log('[auth-server] Token exchanged successfully');
|
|
204
|
+
|
|
205
|
+
// Store token
|
|
206
|
+
const credPath = await storeToken(tokenData);
|
|
207
|
+
console.log(`[auth-server] Credentials saved to ${credPath}`);
|
|
208
|
+
|
|
209
|
+
console.log('\n✓ OAuth flow complete!');
|
|
210
|
+
process.exit(0);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.error(`\n✗ OAuth flow failed: ${err.message}`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Export for use as module
|
|
218
|
+
module.exports = { startAuthServer, exchangeCodeForToken, storeToken };
|
|
219
|
+
|
|
220
|
+
// Run if called directly
|
|
221
|
+
if (require.main === module) {
|
|
222
|
+
main();
|
|
223
|
+
}
|
package/src/database.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview SQLite database wrapper for scheduled LinkedIn posts
|
|
3
|
+
* @module database
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const Database = require('better-sqlite3');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { v4: uuidv4 } = require('uuid');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {import('./types').ScheduledPost} ScheduledPost
|
|
12
|
+
* @typedef {import('./types').ScheduledPostStatus} ScheduledPostStatus
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEFAULT_DB_PATH = path.join(__dirname, '..', 'scheduled_posts.db');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Database wrapper for scheduled posts
|
|
19
|
+
*/
|
|
20
|
+
class ScheduledPostsDB {
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} [dbPath] - Path to the SQLite database file
|
|
23
|
+
*/
|
|
24
|
+
constructor(dbPath = DEFAULT_DB_PATH) {
|
|
25
|
+
this.db = new Database(dbPath);
|
|
26
|
+
this.db.pragma('journal_mode = WAL');
|
|
27
|
+
this._initializeSchema();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Initialize the database schema
|
|
32
|
+
* @private
|
|
33
|
+
*/
|
|
34
|
+
_initializeSchema() {
|
|
35
|
+
this.db.exec(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS scheduled_posts (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
commentary TEXT NOT NULL,
|
|
39
|
+
url TEXT,
|
|
40
|
+
visibility TEXT DEFAULT 'PUBLIC',
|
|
41
|
+
scheduled_time TEXT NOT NULL,
|
|
42
|
+
status TEXT DEFAULT 'pending',
|
|
43
|
+
created_at TEXT NOT NULL,
|
|
44
|
+
published_at TEXT,
|
|
45
|
+
post_urn TEXT,
|
|
46
|
+
error_message TEXT,
|
|
47
|
+
retry_count INTEGER DEFAULT 0
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_status ON scheduled_posts(status);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_time ON scheduled_posts(scheduled_time);
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Add a new scheduled post
|
|
57
|
+
* @param {Object} data - Post data
|
|
58
|
+
* @param {string} data.commentary - Post text
|
|
59
|
+
* @param {string} data.scheduledTime - ISO 8601 datetime string
|
|
60
|
+
* @param {string} [data.url] - Optional URL for link posts
|
|
61
|
+
* @param {string} [data.visibility='PUBLIC'] - Post visibility
|
|
62
|
+
* @returns {ScheduledPost} The created scheduled post
|
|
63
|
+
*/
|
|
64
|
+
addScheduledPost({ commentary, scheduledTime, url = null, visibility = 'PUBLIC' }) {
|
|
65
|
+
const id = uuidv4();
|
|
66
|
+
const createdAt = new Date().toISOString();
|
|
67
|
+
|
|
68
|
+
const stmt = this.db.prepare(`
|
|
69
|
+
INSERT INTO scheduled_posts (id, commentary, url, visibility, scheduled_time, status, created_at)
|
|
70
|
+
VALUES (?, ?, ?, ?, ?, 'pending', ?)
|
|
71
|
+
`);
|
|
72
|
+
|
|
73
|
+
stmt.run(id, commentary, url, visibility, scheduledTime, createdAt);
|
|
74
|
+
|
|
75
|
+
return this.getScheduledPost(id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get a single scheduled post by ID
|
|
80
|
+
* @param {string} id - Post ID
|
|
81
|
+
* @returns {ScheduledPost|null} The scheduled post or null if not found
|
|
82
|
+
*/
|
|
83
|
+
getScheduledPost(id) {
|
|
84
|
+
const stmt = this.db.prepare('SELECT * FROM scheduled_posts WHERE id = ?');
|
|
85
|
+
const row = stmt.get(id);
|
|
86
|
+
return row ? this._rowToPost(row) : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get all scheduled posts, optionally filtered by status
|
|
91
|
+
* @param {ScheduledPostStatus} [status] - Filter by status
|
|
92
|
+
* @param {number} [limit=50] - Maximum number of posts to return
|
|
93
|
+
* @returns {ScheduledPost[]} Array of scheduled posts
|
|
94
|
+
*/
|
|
95
|
+
getScheduledPosts(status = null, limit = 50) {
|
|
96
|
+
let stmt;
|
|
97
|
+
if (status) {
|
|
98
|
+
stmt = this.db.prepare(`
|
|
99
|
+
SELECT * FROM scheduled_posts
|
|
100
|
+
WHERE status = ?
|
|
101
|
+
ORDER BY scheduled_time ASC
|
|
102
|
+
LIMIT ?
|
|
103
|
+
`);
|
|
104
|
+
return stmt.all(status, limit).map(row => this._rowToPost(row));
|
|
105
|
+
} else {
|
|
106
|
+
stmt = this.db.prepare(`
|
|
107
|
+
SELECT * FROM scheduled_posts
|
|
108
|
+
ORDER BY scheduled_time ASC
|
|
109
|
+
LIMIT ?
|
|
110
|
+
`);
|
|
111
|
+
return stmt.all(limit).map(row => this._rowToPost(row));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get posts that are due to be published (scheduled time has passed and status is pending)
|
|
117
|
+
* @returns {ScheduledPost[]} Array of posts ready to publish
|
|
118
|
+
*/
|
|
119
|
+
getDuePosts() {
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const stmt = this.db.prepare(`
|
|
122
|
+
SELECT * FROM scheduled_posts
|
|
123
|
+
WHERE status = 'pending' AND scheduled_time <= ?
|
|
124
|
+
ORDER BY scheduled_time ASC
|
|
125
|
+
`);
|
|
126
|
+
return stmt.all(now).map(row => this._rowToPost(row));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Update post status after successful publish
|
|
131
|
+
* @param {string} id - Post ID
|
|
132
|
+
* @param {string} postUrn - The URN of the published post
|
|
133
|
+
* @returns {ScheduledPost|null} Updated post or null if not found
|
|
134
|
+
*/
|
|
135
|
+
markAsPublished(id, postUrn) {
|
|
136
|
+
const publishedAt = new Date().toISOString();
|
|
137
|
+
const stmt = this.db.prepare(`
|
|
138
|
+
UPDATE scheduled_posts
|
|
139
|
+
SET status = 'published', published_at = ?, post_urn = ?
|
|
140
|
+
WHERE id = ?
|
|
141
|
+
`);
|
|
142
|
+
stmt.run(publishedAt, postUrn, id);
|
|
143
|
+
return this.getScheduledPost(id);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Mark post as failed with error message
|
|
148
|
+
* @param {string} id - Post ID
|
|
149
|
+
* @param {string} errorMessage - Error description
|
|
150
|
+
* @returns {ScheduledPost|null} Updated post or null if not found
|
|
151
|
+
*/
|
|
152
|
+
markAsFailed(id, errorMessage) {
|
|
153
|
+
const stmt = this.db.prepare(`
|
|
154
|
+
UPDATE scheduled_posts
|
|
155
|
+
SET status = 'failed', error_message = ?, retry_count = retry_count + 1
|
|
156
|
+
WHERE id = ?
|
|
157
|
+
`);
|
|
158
|
+
stmt.run(errorMessage, id);
|
|
159
|
+
return this.getScheduledPost(id);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Cancel a scheduled post
|
|
164
|
+
* @param {string} id - Post ID
|
|
165
|
+
* @returns {ScheduledPost|null} Updated post or null if not found
|
|
166
|
+
*/
|
|
167
|
+
cancelPost(id) {
|
|
168
|
+
const stmt = this.db.prepare(`
|
|
169
|
+
UPDATE scheduled_posts
|
|
170
|
+
SET status = 'cancelled'
|
|
171
|
+
WHERE id = ? AND status = 'pending'
|
|
172
|
+
`);
|
|
173
|
+
const result = stmt.run(id);
|
|
174
|
+
if (result.changes === 0) {
|
|
175
|
+
return null; // Post not found or not in pending status
|
|
176
|
+
}
|
|
177
|
+
return this.getScheduledPost(id);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Delete a scheduled post
|
|
182
|
+
* @param {string} id - Post ID
|
|
183
|
+
* @returns {boolean} True if deleted, false if not found
|
|
184
|
+
*/
|
|
185
|
+
deleteScheduledPost(id) {
|
|
186
|
+
const stmt = this.db.prepare('DELETE FROM scheduled_posts WHERE id = ?');
|
|
187
|
+
const result = stmt.run(id);
|
|
188
|
+
return result.changes > 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Reset a failed post to pending for retry
|
|
193
|
+
* @param {string} id - Post ID
|
|
194
|
+
* @returns {ScheduledPost|null} Updated post or null if not found
|
|
195
|
+
*/
|
|
196
|
+
resetForRetry(id) {
|
|
197
|
+
const stmt = this.db.prepare(`
|
|
198
|
+
UPDATE scheduled_posts
|
|
199
|
+
SET status = 'pending', error_message = NULL
|
|
200
|
+
WHERE id = ? AND status = 'failed'
|
|
201
|
+
`);
|
|
202
|
+
const result = stmt.run(id);
|
|
203
|
+
if (result.changes === 0) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return this.getScheduledPost(id);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Update scheduled time for a pending post
|
|
211
|
+
* @param {string} id - Post ID
|
|
212
|
+
* @param {string} newScheduledTime - New ISO 8601 datetime string
|
|
213
|
+
* @returns {ScheduledPost|null} Updated post or null if not found/not pending
|
|
214
|
+
*/
|
|
215
|
+
reschedulePost(id, newScheduledTime) {
|
|
216
|
+
const stmt = this.db.prepare(`
|
|
217
|
+
UPDATE scheduled_posts
|
|
218
|
+
SET scheduled_time = ?
|
|
219
|
+
WHERE id = ? AND status = 'pending'
|
|
220
|
+
`);
|
|
221
|
+
const result = stmt.run(newScheduledTime, id);
|
|
222
|
+
if (result.changes === 0) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
return this.getScheduledPost(id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Convert database row to ScheduledPost object
|
|
230
|
+
* @private
|
|
231
|
+
* @param {Object} row - Database row
|
|
232
|
+
* @returns {ScheduledPost}
|
|
233
|
+
*/
|
|
234
|
+
_rowToPost(row) {
|
|
235
|
+
return {
|
|
236
|
+
id: row.id,
|
|
237
|
+
commentary: row.commentary,
|
|
238
|
+
url: row.url,
|
|
239
|
+
visibility: row.visibility,
|
|
240
|
+
scheduledTime: row.scheduled_time,
|
|
241
|
+
status: row.status,
|
|
242
|
+
createdAt: row.created_at,
|
|
243
|
+
publishedAt: row.published_at,
|
|
244
|
+
postUrn: row.post_urn,
|
|
245
|
+
errorMessage: row.error_message,
|
|
246
|
+
retryCount: row.retry_count
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Close the database connection
|
|
252
|
+
*/
|
|
253
|
+
close() {
|
|
254
|
+
this.db.close();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Singleton instance for the application
|
|
259
|
+
let dbInstance = null;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get the database instance (creates one if needed)
|
|
263
|
+
* @param {string} [dbPath] - Optional path for testing
|
|
264
|
+
* @returns {ScheduledPostsDB}
|
|
265
|
+
*/
|
|
266
|
+
function getDatabase(dbPath) {
|
|
267
|
+
if (!dbInstance || dbPath) {
|
|
268
|
+
dbInstance = new ScheduledPostsDB(dbPath);
|
|
269
|
+
}
|
|
270
|
+
return dbInstance;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Reset the database instance (for testing)
|
|
275
|
+
*/
|
|
276
|
+
function resetDatabase() {
|
|
277
|
+
if (dbInstance) {
|
|
278
|
+
dbInstance.close();
|
|
279
|
+
dbInstance = null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = {
|
|
284
|
+
ScheduledPostsDB,
|
|
285
|
+
getDatabase,
|
|
286
|
+
resetDatabase,
|
|
287
|
+
DEFAULT_DB_PATH
|
|
288
|
+
};
|