@chirag127/omni-publish 0.1.0 → 0.1.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.
- package/LICENSE +21 -21
- package/README.md +66 -66
- package/bin/omni-publish.mjs +291 -0
- package/package.json +9 -5
- package/src/__tests__/publish.test.ts +78 -0
- package/src/adapters/ai-draft.ts +70 -0
- package/src/adapters/bluesky.ts +28 -0
- package/src/adapters/devto.ts +39 -0
- package/src/adapters/hashnode.ts +52 -0
- package/src/adapters/mastodon.ts +35 -0
- package/src/adapters/telegram-drafts.ts +66 -0
- package/src/adapters/threads.ts +45 -0
- package/src/cli.ts +58 -0
- package/src/index.ts +12 -6
- package/src/publish.ts +24 -0
- package/src/types.ts +21 -0
- package/src/__tests__/smoke.test.ts +0 -12
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Chirag Singhal
|
|
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.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chirag Singhal
|
|
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
CHANGED
|
@@ -1,66 +1,66 @@
|
|
|
1
|
-
# @chirag127/omni-publish
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/@chirag127/omni-publish)
|
|
4
|
-
[](https://github.com/chirag127/omni-publish-npm-pkg/actions/workflows/ci.yml)
|
|
5
|
-
[](./LICENSE)
|
|
6
|
-
|
|
7
|
-
Auto-publish release notes / blog posts to multiple platforms on tag push or release create. Platform fan-out is filtered by which env vars are set — if only `DEVTO_API_KEY` exists, only dev.to is targeted.
|
|
8
|
-
|
|
9
|
-
## Install
|
|
10
|
-
|
|
11
|
-
```sh
|
|
12
|
-
npm i -D @chirag127/omni-publish
|
|
13
|
-
# or
|
|
14
|
-
pnpm add -D @chirag127/omni-publish
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## Supported platforms / env vars
|
|
18
|
-
|
|
19
|
-
Set the env var for each platform you want to publish to. Unset = skipped.
|
|
20
|
-
|
|
21
|
-
| Platform | Env var |
|
|
22
|
-
|--------------|----------------------|
|
|
23
|
-
| dev.to | `DEVTO_API_KEY` |
|
|
24
|
-
| Hashnode | `HASHNODE_API_KEY` |
|
|
25
|
-
| Medium | `MEDIUM_API_KEY` |
|
|
26
|
-
| X / Twitter | `X_API_KEY` |
|
|
27
|
-
| LinkedIn | `LINKEDIN_API_KEY` |
|
|
28
|
-
| Bluesky | `BLUESKY_API_KEY` |
|
|
29
|
-
| Mastodon | `MASTODON_API_KEY` |
|
|
30
|
-
| Reddit | `REDDIT_API_KEY` |
|
|
31
|
-
|
|
32
|
-
## GitHub Actions
|
|
33
|
-
|
|
34
|
-
```yaml
|
|
35
|
-
name: Cross-post release
|
|
36
|
-
on:
|
|
37
|
-
release: { types: [published] }
|
|
38
|
-
push: { tags: ['v*.*.*'] }
|
|
39
|
-
jobs:
|
|
40
|
-
publish:
|
|
41
|
-
runs-on: ubuntu-latest
|
|
42
|
-
steps:
|
|
43
|
-
- uses: actions/checkout@v4
|
|
44
|
-
- uses: actions/setup-node@v4
|
|
45
|
-
with: { node-version: 22 }
|
|
46
|
-
- run: npx -y @chirag127/omni-publish
|
|
47
|
-
env:
|
|
48
|
-
DEVTO_API_KEY: ${{ secrets.DEVTO_API_KEY }}
|
|
49
|
-
HASHNODE_API_KEY: ${{ secrets.HASHNODE_API_KEY }}
|
|
50
|
-
MEDIUM_API_KEY: ${{ secrets.MEDIUM_API_KEY }}
|
|
51
|
-
X_API_KEY: ${{ secrets.X_API_KEY }}
|
|
52
|
-
LINKEDIN_API_KEY: ${{ secrets.LINKEDIN_API_KEY }}
|
|
53
|
-
BLUESKY_API_KEY: ${{ secrets.BLUESKY_API_KEY }}
|
|
54
|
-
MASTODON_API_KEY: ${{ secrets.MASTODON_API_KEY }}
|
|
55
|
-
REDDIT_API_KEY: ${{ secrets.REDDIT_API_KEY }}
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
## Status
|
|
59
|
-
|
|
60
|
-
`v0.1.x` — slug reservation + stub. Per-platform adapters land in `v0.1.1`.
|
|
61
|
-
|
|
62
|
-
Part of the [`chirag127/oriz`](https://github.com/chirag127/oriz) family.
|
|
63
|
-
|
|
64
|
-
## Cross-refs
|
|
65
|
-
|
|
66
|
-
- [security policy](./SECURITY.md) · [code of conduct](./CODE_OF_CONDUCT.md) · [contributing](./CONTRIBUTING.md)
|
|
1
|
+
# @chirag127/omni-publish
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@chirag127/omni-publish)
|
|
4
|
+
[](https://github.com/chirag127/omni-publish-npm-pkg/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Auto-publish release notes / blog posts to multiple platforms on tag push or release create. Platform fan-out is filtered by which env vars are set — if only `DEVTO_API_KEY` exists, only dev.to is targeted.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm i -D @chirag127/omni-publish
|
|
13
|
+
# or
|
|
14
|
+
pnpm add -D @chirag127/omni-publish
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Supported platforms / env vars
|
|
18
|
+
|
|
19
|
+
Set the env var for each platform you want to publish to. Unset = skipped.
|
|
20
|
+
|
|
21
|
+
| Platform | Env var |
|
|
22
|
+
|--------------|----------------------|
|
|
23
|
+
| dev.to | `DEVTO_API_KEY` |
|
|
24
|
+
| Hashnode | `HASHNODE_API_KEY` |
|
|
25
|
+
| Medium | `MEDIUM_API_KEY` |
|
|
26
|
+
| X / Twitter | `X_API_KEY` |
|
|
27
|
+
| LinkedIn | `LINKEDIN_API_KEY` |
|
|
28
|
+
| Bluesky | `BLUESKY_API_KEY` |
|
|
29
|
+
| Mastodon | `MASTODON_API_KEY` |
|
|
30
|
+
| Reddit | `REDDIT_API_KEY` |
|
|
31
|
+
|
|
32
|
+
## GitHub Actions
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
name: Cross-post release
|
|
36
|
+
on:
|
|
37
|
+
release: { types: [published] }
|
|
38
|
+
push: { tags: ['v*.*.*'] }
|
|
39
|
+
jobs:
|
|
40
|
+
publish:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
- uses: actions/setup-node@v4
|
|
45
|
+
with: { node-version: 22 }
|
|
46
|
+
- run: npx -y @chirag127/omni-publish
|
|
47
|
+
env:
|
|
48
|
+
DEVTO_API_KEY: ${{ secrets.DEVTO_API_KEY }}
|
|
49
|
+
HASHNODE_API_KEY: ${{ secrets.HASHNODE_API_KEY }}
|
|
50
|
+
MEDIUM_API_KEY: ${{ secrets.MEDIUM_API_KEY }}
|
|
51
|
+
X_API_KEY: ${{ secrets.X_API_KEY }}
|
|
52
|
+
LINKEDIN_API_KEY: ${{ secrets.LINKEDIN_API_KEY }}
|
|
53
|
+
BLUESKY_API_KEY: ${{ secrets.BLUESKY_API_KEY }}
|
|
54
|
+
MASTODON_API_KEY: ${{ secrets.MASTODON_API_KEY }}
|
|
55
|
+
REDDIT_API_KEY: ${{ secrets.REDDIT_API_KEY }}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Status
|
|
59
|
+
|
|
60
|
+
`v0.1.x` — slug reservation + stub. Per-platform adapters land in `v0.1.1`.
|
|
61
|
+
|
|
62
|
+
Part of the [`chirag127/oriz`](https://github.com/chirag127/oriz) family.
|
|
63
|
+
|
|
64
|
+
## Cross-refs
|
|
65
|
+
|
|
66
|
+
- [security policy](./SECURITY.md) · [code of conduct](./CODE_OF_CONDUCT.md) · [contributing](./CONTRIBUTING.md)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// omni-publish bin entry — plain ESM JS so npm doesn't strip the shebang
|
|
3
|
+
// and we don't need a build step. Mirrors src/cli.ts logic via dynamic import
|
|
4
|
+
// of the TS sources... except npm consumers can't run .ts directly.
|
|
5
|
+
//
|
|
6
|
+
// Strategy: this file inlines just enough to do the fan-out in pure JS using
|
|
7
|
+
// Node 22+ native fetch. It imports @atproto/api lazily for Bluesky.
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
10
|
+
const _require = createRequire(import.meta.url)
|
|
11
|
+
void _require
|
|
12
|
+
|
|
13
|
+
function parseArgs(argv) {
|
|
14
|
+
const out = {}
|
|
15
|
+
for (let i = 2; i < argv.length; i++) {
|
|
16
|
+
const a = argv[i]
|
|
17
|
+
if (a.startsWith('--')) {
|
|
18
|
+
const key = a.slice(2)
|
|
19
|
+
const next = argv[i + 1]
|
|
20
|
+
if (next === undefined || next.startsWith('--')) {
|
|
21
|
+
out[key] = 'true'
|
|
22
|
+
} else {
|
|
23
|
+
out[key] = next
|
|
24
|
+
i++
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------- adapters ----------
|
|
32
|
+
|
|
33
|
+
async function devto(input) {
|
|
34
|
+
const apiKey = process.env.DEVTO_API_KEY
|
|
35
|
+
if (!apiKey) return { platform: 'dev.to', status: 'skipped' }
|
|
36
|
+
try {
|
|
37
|
+
const body = {
|
|
38
|
+
article: {
|
|
39
|
+
title: input.title,
|
|
40
|
+
body_markdown: input.body + '\n\n---\n\n*Originally posted at ' + input.canonical_url + '*',
|
|
41
|
+
published: true,
|
|
42
|
+
canonical_url: input.canonical_url,
|
|
43
|
+
tags: input.tags?.slice(0, 4),
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
const res = await fetch('https://dev.to/api/articles', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'api-key': apiKey, 'content-type': 'application/json' },
|
|
49
|
+
body: JSON.stringify(body),
|
|
50
|
+
})
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
const t = await res.text()
|
|
53
|
+
return { platform: 'dev.to', status: 'error', error: `HTTP ${res.status}: ${t.slice(0, 200)}` }
|
|
54
|
+
}
|
|
55
|
+
const json = await res.json()
|
|
56
|
+
return { platform: 'dev.to', status: 'ok', url: json.url }
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return { platform: 'dev.to', status: 'error', error: String(e) }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function hashnode(input) {
|
|
63
|
+
const apiKey = process.env.HASHNODE_API_KEY
|
|
64
|
+
const publicationId = process.env.HASHNODE_PUBLICATION_ID
|
|
65
|
+
if (!apiKey || !publicationId) return { platform: 'hashnode', status: 'skipped' }
|
|
66
|
+
try {
|
|
67
|
+
const MUT = `mutation PublishPost($input: PublishPostInput!) { publishPost(input: $input) { post { id slug url } } }`
|
|
68
|
+
const variables = {
|
|
69
|
+
input: {
|
|
70
|
+
title: input.title,
|
|
71
|
+
contentMarkdown: input.body,
|
|
72
|
+
publicationId,
|
|
73
|
+
originalArticleURL: input.canonical_url,
|
|
74
|
+
tags: (input.tags || []).map((t) => ({ slug: t.toLowerCase().replace(/\s+/g, '-'), name: t })),
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
const res = await fetch('https://gql.hashnode.com/', {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { Authorization: apiKey, 'content-type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ query: MUT, variables }),
|
|
81
|
+
})
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
const t = await res.text()
|
|
84
|
+
return { platform: 'hashnode', status: 'error', error: `HTTP ${res.status}: ${t.slice(0, 200)}` }
|
|
85
|
+
}
|
|
86
|
+
const json = await res.json()
|
|
87
|
+
if (json.errors) return { platform: 'hashnode', status: 'error', error: JSON.stringify(json.errors).slice(0, 200) }
|
|
88
|
+
return { platform: 'hashnode', status: 'ok', url: json.data?.publishPost?.post?.url }
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return { platform: 'hashnode', status: 'error', error: String(e) }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function bluesky(input) {
|
|
95
|
+
const handle = process.env.BLUESKY_HANDLE
|
|
96
|
+
const appPassword = process.env.BLUESKY_APP_PASSWORD
|
|
97
|
+
if (!handle || !appPassword) return { platform: 'bluesky', status: 'skipped' }
|
|
98
|
+
try {
|
|
99
|
+
const mod = await import('@atproto/api')
|
|
100
|
+
const AtpAgent = mod.AtpAgent || mod.default?.AtpAgent
|
|
101
|
+
const agent = new AtpAgent({ service: 'https://bsky.social' })
|
|
102
|
+
await agent.login({ identifier: handle, password: appPassword })
|
|
103
|
+
const text = `${input.title}\n\n${input.canonical_url}`.slice(0, 300)
|
|
104
|
+
const res = await agent.post({ text, createdAt: new Date().toISOString() })
|
|
105
|
+
return { platform: 'bluesky', status: 'ok', url: res.uri }
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return { platform: 'bluesky', status: 'error', error: String(e) }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function mastodon(input) {
|
|
112
|
+
const instance = process.env.MASTODON_INSTANCE
|
|
113
|
+
const token = process.env.MASTODON_ACCESS_TOKEN
|
|
114
|
+
if (!instance || !token) return { platform: 'mastodon', status: 'skipped' }
|
|
115
|
+
try {
|
|
116
|
+
let status = input.title + '\n\n' + input.canonical_url
|
|
117
|
+
if (status.length > 500) status = status.slice(0, 497) + '...'
|
|
118
|
+
const url = `https://${instance.replace(/^https?:\/\//, '').replace(/\/$/, '')}/api/v1/statuses`
|
|
119
|
+
const res = await fetch(url, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
122
|
+
body: JSON.stringify({ status, visibility: 'public' }),
|
|
123
|
+
})
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
const t = await res.text()
|
|
126
|
+
return { platform: 'mastodon', status: 'error', error: `HTTP ${res.status}: ${t.slice(0, 200)}` }
|
|
127
|
+
}
|
|
128
|
+
const json = await res.json()
|
|
129
|
+
return { platform: 'mastodon', status: 'ok', url: json.url }
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { platform: 'mastodon', status: 'error', error: String(e) }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function threads(input) {
|
|
136
|
+
const token = process.env.THREADS_ACCESS_TOKEN
|
|
137
|
+
if (!token) return { platform: 'threads', status: 'skipped' }
|
|
138
|
+
try {
|
|
139
|
+
let text = input.title + '\n\n' + input.canonical_url
|
|
140
|
+
if (text.length > 500) text = text.slice(0, 497) + '...'
|
|
141
|
+
const createUrl = new URL('https://graph.threads.net/v1.0/me/threads')
|
|
142
|
+
createUrl.searchParams.set('media_type', 'TEXT')
|
|
143
|
+
createUrl.searchParams.set('text', text)
|
|
144
|
+
createUrl.searchParams.set('access_token', token)
|
|
145
|
+
const createRes = await fetch(createUrl.toString(), { method: 'POST' })
|
|
146
|
+
if (!createRes.ok) {
|
|
147
|
+
const t = await createRes.text()
|
|
148
|
+
return { platform: 'threads', status: 'error', error: `create HTTP ${createRes.status}: ${t.slice(0, 200)}` }
|
|
149
|
+
}
|
|
150
|
+
const createJson = await createRes.json()
|
|
151
|
+
if (!createJson.id) return { platform: 'threads', status: 'error', error: 'no creation_id returned' }
|
|
152
|
+
const pubUrl = new URL('https://graph.threads.net/v1.0/me/threads_publish')
|
|
153
|
+
pubUrl.searchParams.set('creation_id', createJson.id)
|
|
154
|
+
pubUrl.searchParams.set('access_token', token)
|
|
155
|
+
const pubRes = await fetch(pubUrl.toString(), { method: 'POST' })
|
|
156
|
+
if (!pubRes.ok) {
|
|
157
|
+
const t = await pubRes.text()
|
|
158
|
+
return { platform: 'threads', status: 'error', error: `publish HTTP ${pubRes.status}: ${t.slice(0, 200)}` }
|
|
159
|
+
}
|
|
160
|
+
const pubJson = await pubRes.json()
|
|
161
|
+
return { platform: 'threads', status: 'ok', url: pubJson.id ? `https://www.threads.net/@me/post/${pubJson.id}` : undefined }
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return { platform: 'threads', status: 'error', error: String(e) }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------- AI drafts ----------
|
|
168
|
+
|
|
169
|
+
const PROMPTS = {
|
|
170
|
+
x: (title, body) => `Rewrite this release announcement as a Twitter/X post (280 char max), keeping the canonical URL. Be punchy. Source:\n\n${title}\n\n${body}`,
|
|
171
|
+
reddit: (title, body) => `Rewrite this release announcement as a Reddit self-post. Keep the full body but make it conversational and add a TL;DR at the top. Source:\n\n${title}\n\n${body}`,
|
|
172
|
+
linkedin: (title, body) => `Rewrite this release announcement as a LinkedIn post in a professional, slightly humble tone. ~3 short paragraphs. Source:\n\n${title}\n\n${body}`,
|
|
173
|
+
medium: (title, body) => `Rewrite this release announcement as a 1-paragraph Medium blurb (~80 words) suitable as a post intro. Source:\n\n${title}\n\n${body}`,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function callChat(url, model, key, prompt) {
|
|
177
|
+
try {
|
|
178
|
+
const res = await fetch(url, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
181
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], temperature: 0.7, max_tokens: 512 }),
|
|
182
|
+
})
|
|
183
|
+
if (!res.ok) {
|
|
184
|
+
const text = await res.text()
|
|
185
|
+
return { ok: false, status: res.status, error: text.slice(0, 200) }
|
|
186
|
+
}
|
|
187
|
+
const json = await res.json()
|
|
188
|
+
const content = json.choices?.[0]?.message?.content?.trim()
|
|
189
|
+
if (!content) return { ok: false, error: 'empty response' }
|
|
190
|
+
return { ok: true, text: content }
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return { ok: false, error: String(e) }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function generateDraft(prompt) {
|
|
197
|
+
const nimKey = process.env.NVIDIA_NIM_API_KEY
|
|
198
|
+
const orKey = process.env.OPENROUTER_API_KEY
|
|
199
|
+
if (nimKey) {
|
|
200
|
+
const r = await callChat('https://integrate.api.nvidia.com/v1/chat/completions', 'meta/llama-3.3-70b-instruct', nimKey, prompt)
|
|
201
|
+
if (r.ok && r.text) return r.text
|
|
202
|
+
}
|
|
203
|
+
if (orKey) {
|
|
204
|
+
const r = await callChat('https://openrouter.ai/api/v1/chat/completions', 'meta-llama/llama-3.3-70b-instruct:free', orKey, prompt)
|
|
205
|
+
if (r.ok && r.text) return r.text
|
|
206
|
+
}
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function sendTelegram(token, chatId, text) {
|
|
211
|
+
try {
|
|
212
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: { 'content-type': 'application/json' },
|
|
215
|
+
body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'Markdown', disable_web_page_preview: false }),
|
|
216
|
+
})
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
const t = await res.text()
|
|
219
|
+
return { ok: false, error: `HTTP ${res.status}: ${t.slice(0, 200)}` }
|
|
220
|
+
}
|
|
221
|
+
return { ok: true }
|
|
222
|
+
} catch (e) {
|
|
223
|
+
return { ok: false, error: String(e) }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function makeDraftAdapter(platform, label) {
|
|
228
|
+
return async (input) => {
|
|
229
|
+
const name = `telegram-draft-${platform}`
|
|
230
|
+
const token = process.env.TELEGRAM_DRAFTS_BOT_TOKEN
|
|
231
|
+
const chatId = process.env.TELEGRAM_DRAFTS_CHAT_ID
|
|
232
|
+
if (!token || !chatId) return { platform: name, status: 'skipped' }
|
|
233
|
+
try {
|
|
234
|
+
const draft = await generateDraft(PROMPTS[platform](input.title, input.body))
|
|
235
|
+
if (!draft) return { platform: name, status: 'error', error: 'AI draft generation failed (no NIM/OpenRouter key or both errored)' }
|
|
236
|
+
const text = `📋 DRAFT (${label}) for: ${input.title}\n\n${draft}\n\nOriginal: ${input.canonical_url}`
|
|
237
|
+
const r = await sendTelegram(token, chatId, text)
|
|
238
|
+
if (!r.ok) return { platform: name, status: 'error', error: r.error }
|
|
239
|
+
return { platform: name, status: 'ok' }
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return { platform: name, status: 'error', error: String(e) }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const DRAFT_ADAPTERS = [
|
|
247
|
+
makeDraftAdapter('x', 'X'),
|
|
248
|
+
makeDraftAdapter('reddit', 'Reddit'),
|
|
249
|
+
makeDraftAdapter('linkedin', 'LinkedIn'),
|
|
250
|
+
makeDraftAdapter('medium', 'Medium'),
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
const AUTO_ADAPTERS = [devto, hashnode, bluesky, mastodon, threads]
|
|
254
|
+
|
|
255
|
+
async function publishAll(input) {
|
|
256
|
+
const all = [...AUTO_ADAPTERS, ...DRAFT_ADAPTERS]
|
|
257
|
+
return Promise.all(
|
|
258
|
+
all.map((fn) =>
|
|
259
|
+
fn(input).catch((e) => ({ platform: fn.name || 'unknown', status: 'error', error: String(e) }))
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function main() {
|
|
265
|
+
const args = parseArgs(process.argv)
|
|
266
|
+
if (!args.title || !args.canonical) {
|
|
267
|
+
console.error('Usage: omni-publish --title "..." --body "..." --canonical "..." --type <app|package|book|extension> [--tags a,b,c]')
|
|
268
|
+
process.exit(1)
|
|
269
|
+
}
|
|
270
|
+
const input = {
|
|
271
|
+
title: args.title,
|
|
272
|
+
body: args.body || '',
|
|
273
|
+
canonical_url: args.canonical,
|
|
274
|
+
type: args.type || 'package',
|
|
275
|
+
tags: args.tags ? args.tags.split(',').map((t) => t.trim()).filter(Boolean) : undefined,
|
|
276
|
+
}
|
|
277
|
+
const results = await publishAll(input)
|
|
278
|
+
for (const r of results) {
|
|
279
|
+
const sym = r.status === 'ok' ? '✓' : r.status === 'skipped' ? '-' : '✗'
|
|
280
|
+
let line = `${sym} ${r.platform}`
|
|
281
|
+
if (r.url) line += ` → ${r.url}`
|
|
282
|
+
if (r.error) line += ` (${r.error})`
|
|
283
|
+
console.log(line)
|
|
284
|
+
}
|
|
285
|
+
process.exit(0)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
main().catch((e) => {
|
|
289
|
+
console.error('omni-publish fatal:', e)
|
|
290
|
+
process.exit(1)
|
|
291
|
+
})
|
package/package.json
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chirag127/omni-publish",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Auto-publish release notes / blog posts to multiple platforms (dev.to,
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Auto-publish release notes / blog posts to multiple platforms (dev.to, Hashnode, Bluesky, Mastodon, Threads) + Telegram drafts queue for manual platforms (X, Reddit, LinkedIn, Medium). Platform fan-out is env-gated.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Chirag Singhal <chirag@oriz.in>",
|
|
8
8
|
"homepage": "https://github.com/chirag127/omni-publish-npm-pkg#readme",
|
|
9
9
|
"repository": { "type": "git", "url": "git+https://github.com/chirag127/omni-publish-npm-pkg.git" },
|
|
10
10
|
"bugs": { "url": "https://github.com/chirag127/omni-publish-npm-pkg/issues" },
|
|
11
|
-
"keywords": ["chirag127", "oriz", "release-notes", "devto", "hashnode", "medium", "twitter", "linkedin", "bluesky", "mastodon", "reddit", "cross-post"],
|
|
12
|
-
"files": ["src", "LICENSE", "README.md"],
|
|
11
|
+
"keywords": ["chirag127", "oriz", "release-notes", "devto", "hashnode", "medium", "twitter", "linkedin", "bluesky", "mastodon", "reddit", "threads", "cross-post", "telegram-drafts"],
|
|
12
|
+
"files": ["src", "bin", "LICENSE", "README.md"],
|
|
13
13
|
"main": "./src/index.ts",
|
|
14
14
|
"types": "./src/index.ts",
|
|
15
|
-
"bin": { "omni-publish": "./
|
|
15
|
+
"bin": { "omni-publish": "./bin/omni-publish.mjs" },
|
|
16
16
|
"exports": { ".": "./src/index.ts" },
|
|
17
17
|
"publishConfig": { "access": "public" },
|
|
18
|
+
"engines": { "node": ">=22" },
|
|
18
19
|
"scripts": {
|
|
19
20
|
"test": "vitest run",
|
|
20
21
|
"test:watch": "vitest",
|
|
21
22
|
"test:coverage": "vitest run --coverage"
|
|
22
23
|
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@atproto/api": "^0.13.0"
|
|
26
|
+
},
|
|
23
27
|
"devDependencies": {
|
|
24
28
|
"vitest": "^2.0.0",
|
|
25
29
|
"@vitest/coverage-v8": "^2.0.0",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { __pkg, publish } from '../index'
|
|
3
|
+
import * as devto from '../adapters/devto'
|
|
4
|
+
import * as hashnode from '../adapters/hashnode'
|
|
5
|
+
import * as bluesky from '../adapters/bluesky'
|
|
6
|
+
import * as mastodon from '../adapters/mastodon'
|
|
7
|
+
import * as threads from '../adapters/threads'
|
|
8
|
+
import { DRAFT_ADAPTERS } from '../adapters/telegram-drafts'
|
|
9
|
+
|
|
10
|
+
// Snapshot env vars relevant to omni-publish, clear them for these tests,
|
|
11
|
+
// and restore afterwards.
|
|
12
|
+
const ENV_VARS = [
|
|
13
|
+
'DEVTO_API_KEY',
|
|
14
|
+
'HASHNODE_API_KEY',
|
|
15
|
+
'HASHNODE_PUBLICATION_ID',
|
|
16
|
+
'BLUESKY_HANDLE',
|
|
17
|
+
'BLUESKY_APP_PASSWORD',
|
|
18
|
+
'MASTODON_INSTANCE',
|
|
19
|
+
'MASTODON_ACCESS_TOKEN',
|
|
20
|
+
'THREADS_ACCESS_TOKEN',
|
|
21
|
+
'NVIDIA_NIM_API_KEY',
|
|
22
|
+
'OPENROUTER_API_KEY',
|
|
23
|
+
'TELEGRAM_DRAFTS_BOT_TOKEN',
|
|
24
|
+
'TELEGRAM_DRAFTS_CHAT_ID',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
let saved: Record<string, string | undefined> = {}
|
|
28
|
+
|
|
29
|
+
describe('package smoke', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
saved = {}
|
|
32
|
+
for (const k of ENV_VARS) {
|
|
33
|
+
saved[k] = process.env[k]
|
|
34
|
+
delete process.env[k]
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
for (const k of ENV_VARS) {
|
|
40
|
+
if (saved[k] === undefined) delete process.env[k]
|
|
41
|
+
else process.env[k] = saved[k]
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('exports __pkg constant', () => {
|
|
46
|
+
expect(__pkg).toMatch(/^@chirag127\//)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('each auto-adapter exports a name string', () => {
|
|
50
|
+
expect(typeof devto.name).toBe('string')
|
|
51
|
+
expect(typeof hashnode.name).toBe('string')
|
|
52
|
+
expect(typeof bluesky.name).toBe('string')
|
|
53
|
+
expect(typeof mastodon.name).toBe('string')
|
|
54
|
+
expect(typeof threads.name).toBe('string')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('each draft adapter exports a name string', () => {
|
|
58
|
+
for (const a of DRAFT_ADAPTERS) {
|
|
59
|
+
expect(typeof a.name).toBe('string')
|
|
60
|
+
expect(a.name.startsWith('telegram-draft-')).toBe(true)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('publish() returns all-skipped results when no env vars are set', async () => {
|
|
65
|
+
const results = await publish({
|
|
66
|
+
title: 'test',
|
|
67
|
+
body: 'b',
|
|
68
|
+
canonical_url: 'https://example.com',
|
|
69
|
+
type: 'package',
|
|
70
|
+
tags: ['t'],
|
|
71
|
+
})
|
|
72
|
+
expect(Array.isArray(results)).toBe(true)
|
|
73
|
+
expect(results.length).toBe(9) // 5 auto + 4 draft
|
|
74
|
+
for (const r of results) {
|
|
75
|
+
expect(r.status).toBe('skipped')
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// AI draft generator — NVIDIA NIM primary, OpenRouter fallback
|
|
2
|
+
// Used by telegram-drafts adapter to rewrite release announcements per-platform.
|
|
3
|
+
|
|
4
|
+
const NIM_URL = 'https://integrate.api.nvidia.com/v1/chat/completions'
|
|
5
|
+
const NIM_MODEL = 'meta/llama-3.3-70b-instruct'
|
|
6
|
+
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
|
7
|
+
const OPENROUTER_MODEL = 'meta-llama/llama-3.3-70b-instruct:free'
|
|
8
|
+
|
|
9
|
+
async function callChat(url: string, model: string, key: string, prompt: string): Promise<{ ok: boolean; text?: string; status?: number; error?: string }> {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(url, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: {
|
|
14
|
+
Authorization: `Bearer ${key}`,
|
|
15
|
+
'content-type': 'application/json',
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify({
|
|
18
|
+
model,
|
|
19
|
+
messages: [{ role: 'user', content: prompt }],
|
|
20
|
+
temperature: 0.7,
|
|
21
|
+
max_tokens: 512,
|
|
22
|
+
}),
|
|
23
|
+
})
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
const text = await res.text()
|
|
26
|
+
return { ok: false, status: res.status, error: text.slice(0, 200) }
|
|
27
|
+
}
|
|
28
|
+
const json = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> }
|
|
29
|
+
const content = json.choices?.[0]?.message?.content?.trim()
|
|
30
|
+
if (!content) return { ok: false, error: 'empty response' }
|
|
31
|
+
return { ok: true, text: content }
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return { ok: false, error: String(e) }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function generateDraft(prompt: string): Promise<string | null> {
|
|
38
|
+
const nimKey = process.env.NVIDIA_NIM_API_KEY
|
|
39
|
+
const orKey = process.env.OPENROUTER_API_KEY
|
|
40
|
+
|
|
41
|
+
// Try NIM first
|
|
42
|
+
if (nimKey) {
|
|
43
|
+
const r = await callChat(NIM_URL, NIM_MODEL, nimKey, prompt)
|
|
44
|
+
if (r.ok && r.text) return r.text
|
|
45
|
+
// Fall through on 429/5xx
|
|
46
|
+
if (r.status && r.status !== 429 && r.status < 500) {
|
|
47
|
+
// 4xx other than 429 — don't fall back
|
|
48
|
+
if (!orKey) return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fallback to OpenRouter
|
|
53
|
+
if (orKey) {
|
|
54
|
+
const r = await callChat(OPENROUTER_URL, OPENROUTER_MODEL, orKey, prompt)
|
|
55
|
+
if (r.ok && r.text) return r.text
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const PROMPTS = {
|
|
62
|
+
x: (title: string, body: string) =>
|
|
63
|
+
`Rewrite this release announcement as a Twitter/X post (280 char max), keeping the canonical URL. Be punchy. Source:\n\n${title}\n\n${body}`,
|
|
64
|
+
reddit: (title: string, body: string) =>
|
|
65
|
+
`Rewrite this release announcement as a Reddit self-post. Keep the full body but make it conversational and add a TL;DR at the top. Source:\n\n${title}\n\n${body}`,
|
|
66
|
+
linkedin: (title: string, body: string) =>
|
|
67
|
+
`Rewrite this release announcement as a LinkedIn post in a professional, slightly humble tone. ~3 short paragraphs. Source:\n\n${title}\n\n${body}`,
|
|
68
|
+
medium: (title: string, body: string) =>
|
|
69
|
+
`Rewrite this release announcement as a 1-paragraph Medium blurb (~80 words) suitable as a post intro. Source:\n\n${title}\n\n${body}`,
|
|
70
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Bluesky adapter — AT Protocol
|
|
2
|
+
import type { PublishInput, PublishResult } from '../types.js'
|
|
3
|
+
|
|
4
|
+
export const name = 'bluesky'
|
|
5
|
+
|
|
6
|
+
export async function publish(input: PublishInput): Promise<PublishResult> {
|
|
7
|
+
const handle = process.env.BLUESKY_HANDLE
|
|
8
|
+
const appPassword = process.env.BLUESKY_APP_PASSWORD
|
|
9
|
+
if (!handle || !appPassword) {
|
|
10
|
+
return { platform: name, status: 'skipped' }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
// Dynamic import so the dep is only loaded when needed
|
|
15
|
+
const { AtpAgent } = await import('@atproto/api')
|
|
16
|
+
const agent = new AtpAgent({ service: 'https://bsky.social' })
|
|
17
|
+
await agent.login({ identifier: handle, password: appPassword })
|
|
18
|
+
|
|
19
|
+
const text = `${input.title}\n\n${input.canonical_url}`.slice(0, 300)
|
|
20
|
+
const res = await agent.post({
|
|
21
|
+
text,
|
|
22
|
+
createdAt: new Date().toISOString(),
|
|
23
|
+
})
|
|
24
|
+
return { platform: name, status: 'ok', url: res.uri }
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return { platform: name, status: 'error', error: String(e) }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// dev.to adapter
|
|
2
|
+
import type { PublishInput, PublishResult } from '../types.js'
|
|
3
|
+
|
|
4
|
+
export const name = 'dev.to'
|
|
5
|
+
|
|
6
|
+
export async function publish(input: PublishInput): Promise<PublishResult> {
|
|
7
|
+
const apiKey = process.env.DEVTO_API_KEY
|
|
8
|
+
if (!apiKey) {
|
|
9
|
+
return { platform: name, status: 'skipped' }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const body = {
|
|
14
|
+
article: {
|
|
15
|
+
title: input.title,
|
|
16
|
+
body_markdown: input.body + '\n\n---\n\n*Originally posted at ' + input.canonical_url + '*',
|
|
17
|
+
published: true,
|
|
18
|
+
canonical_url: input.canonical_url,
|
|
19
|
+
tags: input.tags?.slice(0, 4),
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
const res = await fetch('https://dev.to/api/articles', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
'api-key': apiKey,
|
|
26
|
+
'content-type': 'application/json',
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify(body),
|
|
29
|
+
})
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const text = await res.text()
|
|
32
|
+
return { platform: name, status: 'error', error: `HTTP ${res.status}: ${text.slice(0, 200)}` }
|
|
33
|
+
}
|
|
34
|
+
const json = (await res.json()) as { url?: string }
|
|
35
|
+
return { platform: name, status: 'ok', url: json.url }
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return { platform: name, status: 'error', error: String(e) }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Hashnode adapter — GraphQL
|
|
2
|
+
import type { PublishInput, PublishResult } from '../types.js'
|
|
3
|
+
|
|
4
|
+
export const name = 'hashnode'
|
|
5
|
+
|
|
6
|
+
const MUTATION = `mutation PublishPost($input: PublishPostInput!) {
|
|
7
|
+
publishPost(input: $input) {
|
|
8
|
+
post { id slug url }
|
|
9
|
+
}
|
|
10
|
+
}`
|
|
11
|
+
|
|
12
|
+
export async function publish(input: PublishInput): Promise<PublishResult> {
|
|
13
|
+
const apiKey = process.env.HASHNODE_API_KEY
|
|
14
|
+
const publicationId = process.env.HASHNODE_PUBLICATION_ID
|
|
15
|
+
if (!apiKey || !publicationId) {
|
|
16
|
+
return { platform: name, status: 'skipped' }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const variables = {
|
|
21
|
+
input: {
|
|
22
|
+
title: input.title,
|
|
23
|
+
contentMarkdown: input.body,
|
|
24
|
+
publicationId,
|
|
25
|
+
originalArticleURL: input.canonical_url,
|
|
26
|
+
tags: (input.tags || []).map((t) => ({
|
|
27
|
+
slug: t.toLowerCase().replace(/\s+/g, '-'),
|
|
28
|
+
name: t,
|
|
29
|
+
})),
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
const res = await fetch('https://gql.hashnode.com/', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
Authorization: apiKey,
|
|
36
|
+
'content-type': 'application/json',
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify({ query: MUTATION, variables }),
|
|
39
|
+
})
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
const text = await res.text()
|
|
42
|
+
return { platform: name, status: 'error', error: `HTTP ${res.status}: ${text.slice(0, 200)}` }
|
|
43
|
+
}
|
|
44
|
+
const json = (await res.json()) as { data?: { publishPost?: { post?: { url?: string } } }; errors?: unknown }
|
|
45
|
+
if (json.errors) {
|
|
46
|
+
return { platform: name, status: 'error', error: JSON.stringify(json.errors).slice(0, 200) }
|
|
47
|
+
}
|
|
48
|
+
return { platform: name, status: 'ok', url: json.data?.publishPost?.post?.url }
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return { platform: name, status: 'error', error: String(e) }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Mastodon adapter
|
|
2
|
+
import type { PublishInput, PublishResult } from '../types.js'
|
|
3
|
+
|
|
4
|
+
export const name = 'mastodon'
|
|
5
|
+
|
|
6
|
+
export async function publish(input: PublishInput): Promise<PublishResult> {
|
|
7
|
+
const instance = process.env.MASTODON_INSTANCE
|
|
8
|
+
const token = process.env.MASTODON_ACCESS_TOKEN
|
|
9
|
+
if (!instance || !token) {
|
|
10
|
+
return { platform: name, status: 'skipped' }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
let status = input.title + '\n\n' + input.canonical_url
|
|
15
|
+
if (status.length > 500) status = status.slice(0, 497) + '...'
|
|
16
|
+
|
|
17
|
+
const url = `https://${instance.replace(/^https?:\/\//, '').replace(/\/$/, '')}/api/v1/statuses`
|
|
18
|
+
const res = await fetch(url, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
'content-type': 'application/json',
|
|
23
|
+
},
|
|
24
|
+
body: JSON.stringify({ status, visibility: 'public' }),
|
|
25
|
+
})
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const text = await res.text()
|
|
28
|
+
return { platform: name, status: 'error', error: `HTTP ${res.status}: ${text.slice(0, 200)}` }
|
|
29
|
+
}
|
|
30
|
+
const json = (await res.json()) as { url?: string }
|
|
31
|
+
return { platform: name, status: 'ok', url: json.url }
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return { platform: name, status: 'error', error: String(e) }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Telegram drafts queue — for manual platforms (X, Reddit, LinkedIn, Medium)
|
|
2
|
+
// Generates an AI draft per platform and posts it to the Telegram drafts channel
|
|
3
|
+
// so the user can review + post manually.
|
|
4
|
+
|
|
5
|
+
import type { PublishInput, PublishResult } from '../types.js'
|
|
6
|
+
import { generateDraft, PROMPTS } from './ai-draft.js'
|
|
7
|
+
|
|
8
|
+
type PlatformKey = 'x' | 'reddit' | 'linkedin' | 'medium'
|
|
9
|
+
|
|
10
|
+
async function sendTelegram(token: string, chatId: string, text: string): Promise<{ ok: boolean; error?: string }> {
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: { 'content-type': 'application/json' },
|
|
15
|
+
body: JSON.stringify({
|
|
16
|
+
chat_id: chatId,
|
|
17
|
+
text,
|
|
18
|
+
parse_mode: 'Markdown',
|
|
19
|
+
disable_web_page_preview: false,
|
|
20
|
+
}),
|
|
21
|
+
})
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
const t = await res.text()
|
|
24
|
+
return { ok: false, error: `HTTP ${res.status}: ${t.slice(0, 200)}` }
|
|
25
|
+
}
|
|
26
|
+
return { ok: true }
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return { ok: false, error: String(e) }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeAdapter(platform: PlatformKey, label: string) {
|
|
33
|
+
return {
|
|
34
|
+
name: `telegram-draft-${platform}`,
|
|
35
|
+
async publish(input: PublishInput): Promise<PublishResult> {
|
|
36
|
+
const token = process.env.TELEGRAM_DRAFTS_BOT_TOKEN
|
|
37
|
+
const chatId = process.env.TELEGRAM_DRAFTS_CHAT_ID
|
|
38
|
+
if (!token || !chatId) {
|
|
39
|
+
return { platform: this.name, status: 'skipped' }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const prompt = PROMPTS[platform](input.title, input.body)
|
|
44
|
+
const draft = await generateDraft(prompt)
|
|
45
|
+
if (!draft) {
|
|
46
|
+
return { platform: this.name, status: 'error', error: 'AI draft generation failed (no NIM/OpenRouter key or both errored)' }
|
|
47
|
+
}
|
|
48
|
+
const text = `📋 DRAFT (${label}) for: ${input.title}\n\n${draft}\n\nOriginal: ${input.canonical_url}`
|
|
49
|
+
const r = await sendTelegram(token, chatId, text)
|
|
50
|
+
if (!r.ok) {
|
|
51
|
+
return { platform: this.name, status: 'error', error: r.error }
|
|
52
|
+
}
|
|
53
|
+
return { platform: this.name, status: 'ok' }
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return { platform: this.name, status: 'error', error: String(e) }
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const xDraft = makeAdapter('x', 'X')
|
|
62
|
+
export const redditDraft = makeAdapter('reddit', 'Reddit')
|
|
63
|
+
export const linkedinDraft = makeAdapter('linkedin', 'LinkedIn')
|
|
64
|
+
export const mediumDraft = makeAdapter('medium', 'Medium')
|
|
65
|
+
|
|
66
|
+
export const DRAFT_ADAPTERS = [xDraft, redditDraft, linkedinDraft, mediumDraft]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Threads (Meta) adapter — 2-step create + publish
|
|
2
|
+
import type { PublishInput, PublishResult } from '../types.js'
|
|
3
|
+
|
|
4
|
+
export const name = 'threads'
|
|
5
|
+
|
|
6
|
+
export async function publish(input: PublishInput): Promise<PublishResult> {
|
|
7
|
+
const token = process.env.THREADS_ACCESS_TOKEN
|
|
8
|
+
if (!token) {
|
|
9
|
+
return { platform: name, status: 'skipped' }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
let text = input.title + '\n\n' + input.canonical_url
|
|
14
|
+
if (text.length > 500) text = text.slice(0, 497) + '...'
|
|
15
|
+
|
|
16
|
+
// Step 1: create container
|
|
17
|
+
const createUrl = new URL('https://graph.threads.net/v1.0/me/threads')
|
|
18
|
+
createUrl.searchParams.set('media_type', 'TEXT')
|
|
19
|
+
createUrl.searchParams.set('text', text)
|
|
20
|
+
createUrl.searchParams.set('access_token', token)
|
|
21
|
+
const createRes = await fetch(createUrl.toString(), { method: 'POST' })
|
|
22
|
+
if (!createRes.ok) {
|
|
23
|
+
const t = await createRes.text()
|
|
24
|
+
return { platform: name, status: 'error', error: `create HTTP ${createRes.status}: ${t.slice(0, 200)}` }
|
|
25
|
+
}
|
|
26
|
+
const createJson = (await createRes.json()) as { id?: string }
|
|
27
|
+
if (!createJson.id) {
|
|
28
|
+
return { platform: name, status: 'error', error: 'no creation_id returned' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Step 2: publish
|
|
32
|
+
const pubUrl = new URL('https://graph.threads.net/v1.0/me/threads_publish')
|
|
33
|
+
pubUrl.searchParams.set('creation_id', createJson.id)
|
|
34
|
+
pubUrl.searchParams.set('access_token', token)
|
|
35
|
+
const pubRes = await fetch(pubUrl.toString(), { method: 'POST' })
|
|
36
|
+
if (!pubRes.ok) {
|
|
37
|
+
const t = await pubRes.text()
|
|
38
|
+
return { platform: name, status: 'error', error: `publish HTTP ${pubRes.status}: ${t.slice(0, 200)}` }
|
|
39
|
+
}
|
|
40
|
+
const pubJson = (await pubRes.json()) as { id?: string }
|
|
41
|
+
return { platform: name, status: 'ok', url: pubJson.id ? `https://www.threads.net/@me/post/${pubJson.id}` : undefined }
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return { platform: name, status: 'error', error: String(e) }
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// omni-publish CLI
|
|
3
|
+
// Usage: omni-publish --title "..." --body "..." --canonical "..." --type package --tags astro,oriz
|
|
4
|
+
import { publish } from './publish.js'
|
|
5
|
+
import type { PublishInput } from './types.js'
|
|
6
|
+
|
|
7
|
+
function parseArgs(argv: string[]): Record<string, string> {
|
|
8
|
+
const out: Record<string, string> = {}
|
|
9
|
+
for (let i = 2; i < argv.length; i++) {
|
|
10
|
+
const a = argv[i]
|
|
11
|
+
if (a.startsWith('--')) {
|
|
12
|
+
const key = a.slice(2)
|
|
13
|
+
const next = argv[i + 1]
|
|
14
|
+
if (next === undefined || next.startsWith('--')) {
|
|
15
|
+
out[key] = 'true'
|
|
16
|
+
} else {
|
|
17
|
+
out[key] = next
|
|
18
|
+
i++
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
const args = parseArgs(process.argv)
|
|
27
|
+
if (!args.title || !args.canonical) {
|
|
28
|
+
console.error('Usage: omni-publish --title "..." --body "..." --canonical "..." --type <app|package|book|extension> [--tags a,b,c]')
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
const type = (args.type as PublishInput['type']) || 'package'
|
|
32
|
+
const input: PublishInput = {
|
|
33
|
+
title: args.title,
|
|
34
|
+
body: args.body || '',
|
|
35
|
+
canonical_url: args.canonical,
|
|
36
|
+
type,
|
|
37
|
+
tags: args.tags ? args.tags.split(',').map((t) => t.trim()).filter(Boolean) : undefined,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const results = await publish(input)
|
|
41
|
+
let anyError = false
|
|
42
|
+
for (const r of results) {
|
|
43
|
+
const sym = r.status === 'ok' ? '✓' : r.status === 'skipped' ? '-' : '✗'
|
|
44
|
+
let line = `${sym} ${r.platform}`
|
|
45
|
+
if (r.url) line += ` → ${r.url}`
|
|
46
|
+
if (r.error) line += ` (${r.error})`
|
|
47
|
+
console.log(line)
|
|
48
|
+
if (r.status === 'error') anyError = true
|
|
49
|
+
}
|
|
50
|
+
// Don't fail the workflow on per-platform errors — env-gated by design.
|
|
51
|
+
process.exit(0)
|
|
52
|
+
void anyError
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
main().catch((e) => {
|
|
56
|
+
console.error('omni-publish fatal:', e)
|
|
57
|
+
process.exit(1)
|
|
58
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
// @chirag127/omni-publish
|
|
3
|
-
// v0.1.
|
|
2
|
+
// v0.1.1 — 5 working adapters (dev.to, Hashnode, Bluesky, Mastodon, Threads)
|
|
3
|
+
// + Telegram drafts queue for 4 manual platforms (X, Reddit, LinkedIn, Medium).
|
|
4
|
+
//
|
|
5
|
+
// Library API:
|
|
6
|
+
// import { publish, type PublishInput, type PublishResult } from '@chirag127/omni-publish'
|
|
7
|
+
//
|
|
8
|
+
// CLI (Node 22+):
|
|
9
|
+
// npx @chirag127/omni-publish --title "..." --body "..." --canonical "..." --type package --tags a,b
|
|
4
10
|
export const __pkg = '@chirag127/omni-publish' as const
|
|
5
11
|
|
|
12
|
+
export type { PublishInput, PublishResult, Adapter } from './types.js'
|
|
13
|
+
export { publish } from './publish.js'
|
|
14
|
+
|
|
15
|
+
// Legacy config type kept for v0.1.0 back-compat (unused by new publish()).
|
|
6
16
|
export type OmniPublishConfig = {
|
|
7
17
|
platforms: string[]
|
|
8
18
|
title: string
|
|
9
19
|
body: string
|
|
10
20
|
canonicalUrl?: string
|
|
11
21
|
}
|
|
12
|
-
|
|
13
|
-
export async function publish(_cfg: OmniPublishConfig): Promise<void> {
|
|
14
|
-
/* TODO: implement per-platform adapters in v0.1.1 */
|
|
15
|
-
}
|
package/src/publish.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Main publish orchestrator — fans out to auto-publish adapters and Telegram drafts.
|
|
2
|
+
import type { PublishInput, PublishResult } from './types.js'
|
|
3
|
+
import * as devto from './adapters/devto.js'
|
|
4
|
+
import * as hashnode from './adapters/hashnode.js'
|
|
5
|
+
import * as bluesky from './adapters/bluesky.js'
|
|
6
|
+
import * as mastodon from './adapters/mastodon.js'
|
|
7
|
+
import * as threads from './adapters/threads.js'
|
|
8
|
+
import { DRAFT_ADAPTERS } from './adapters/telegram-drafts.js'
|
|
9
|
+
|
|
10
|
+
const AUTO_ADAPTERS = [devto, hashnode, bluesky, mastodon, threads]
|
|
11
|
+
|
|
12
|
+
export async function publish(input: PublishInput): Promise<PublishResult[]> {
|
|
13
|
+
const autoResults = await Promise.all(
|
|
14
|
+
AUTO_ADAPTERS.map((a) =>
|
|
15
|
+
a.publish(input).catch((e): PublishResult => ({ platform: a.name, status: 'error', error: String(e) }))
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
const draftResults = await Promise.all(
|
|
19
|
+
DRAFT_ADAPTERS.map((a) =>
|
|
20
|
+
a.publish(input).catch((e): PublishResult => ({ platform: a.name, status: 'error', error: String(e) }))
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
return [...autoResults, ...draftResults]
|
|
24
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Shared types for @chirag127/omni-publish
|
|
2
|
+
|
|
3
|
+
export type PublishInput = {
|
|
4
|
+
title: string
|
|
5
|
+
body: string
|
|
6
|
+
canonical_url: string
|
|
7
|
+
type: 'app' | 'package' | 'book' | 'extension'
|
|
8
|
+
tags?: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type PublishResult = {
|
|
12
|
+
platform: string
|
|
13
|
+
status: 'ok' | 'skipped' | 'error'
|
|
14
|
+
url?: string
|
|
15
|
+
error?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Adapter = {
|
|
19
|
+
name: string
|
|
20
|
+
publish: (input: PublishInput) => Promise<PublishResult>
|
|
21
|
+
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { __pkg, publish } from '../index'
|
|
3
|
-
|
|
4
|
-
describe('package smoke', () => {
|
|
5
|
-
it('exports __pkg constant', () => {
|
|
6
|
-
expect(__pkg).toMatch(/^@chirag127\//)
|
|
7
|
-
})
|
|
8
|
-
|
|
9
|
-
it('publish() resolves without throwing on empty config', async () => {
|
|
10
|
-
await expect(publish({ platforms: [], title: 't', body: 'b' })).resolves.toBeUndefined()
|
|
11
|
-
})
|
|
12
|
-
})
|