Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The API is split into two parts:
- `TelegramStrategy`
- `DevtoStrategy`
- `NostrStrategy` (requires Node.js v22+)
- `RedditStrategy`

Each strategy requires its own parameters that are specific to the service. If you only want to post to a particular service, you can just directly use the strategy for that service.

Expand All @@ -46,6 +47,7 @@ import {
TelegramStrategy,
DevtoStrategy,
NostrStrategy,
RedditStrategy,
} from "@humanwhocodes/crosspost";

// Note: Use an app password, not your login password!
Expand Down Expand Up @@ -102,6 +104,12 @@ const nostr = new NostrStrategy({
relays: ["wss://relay.example.com", "wss://relay2.example.com"],
});

// Note: OAuth token and subreddit required
const reddit = new RedditStrategy({
accessToken: "your-access-token",
subreddit: "javascript",
});

// create a client that will post to all services
const client = new Client({
strategies: [
Expand All @@ -114,6 +122,7 @@ const client = new Client({
telegram,
devto,
nostr,
reddit,
],
});

Expand Down Expand Up @@ -185,6 +194,7 @@ Usage: crosspost [options] ["Message to post."]
--telegram Post to Telegram.
--slack, -s Post to Slack.
--nostr, -n Post to Nostr.
--reddit, -r Post to Reddit.
--mcp Start MCP server.
--file The file to read the message from.
--image The image file to upload with the message.
Expand Down Expand Up @@ -247,6 +257,9 @@ Each strategy requires a set of environment variables in order to execute:
- Nostr
- `NOSTR_PRIVATE_KEY`
- `NOSTR_RELAYS`
- Reddit
- `REDDIT_ACCESS_TOKEN`
- `REDDIT_SUBREDDIT`

Tip: You can load environment variables from a `.env` file by setting the environment variable `CROSSPOST_DOTENV`. Set it to `1` to use `.env` in the current working directory, or set it to a specific filepath to use a different location.

Expand Down Expand Up @@ -507,6 +520,30 @@ Nostr posts are "short text notes" (kind 1 events) with a 280 character limit. I

**Security:** Keep your private key secure and never share it. Consider using a dedicated key for crossposting rather than your main Nostr identity key.

### Reddit

To enable posting to Reddit:

1. Go to [Reddit Apps](https://www.reddit.com/prefs/apps) and click "create another app...".
2. Enter a name for your app and choose **script** as the app type.
3. Set `http://localhost:8080` as the redirect URI and click "create app".
4. Note the app's client ID and secret from the app details.
5. Generate an OAuth access token for your script app (see the [Reddit OAuth API docs](https://www.reddit.com/dev/api/oauth)). Example:

```shell
curl -u "<CLIENT_ID>:<CLIENT_SECRET>" \
-d "grant_type=password&username=<REDDIT_USERNAME>&password=<REDDIT_PASSWORD>" \
-A "Crosspost by u/<REDDIT_USERNAME>" \
https://www.reddit.com/api/v1/access_token
```

Copy the `access_token` value from the JSON response and set it as `REDDIT_ACCESS_TOKEN`.
6. Set `REDDIT_SUBREDDIT` to the target community name (without `r/`).

Reddit submissions created by this strategy are self/text posts. The first line before the first newline character (`\n`) is used as the post title and remaining lines are used as the post body.

For example, `"Post title\n\nPost body"` sends `Post title` as the title and `Post body` as the body. If there is only one line, then only the title is sent and the body is empty.

## License

Copyright 2024-2025 Nicholas C. Zakas
Expand Down
13 changes: 13 additions & 0 deletions src/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
TelegramStrategy,
SlackStrategy,
NostrStrategy,
RedditStrategy,
} from "./index.js";
import { CrosspostMcpServer } from "./mcp-server.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
Expand Down Expand Up @@ -66,6 +67,7 @@ const options = {
telegram: { type: booleanType },
slack: { type: booleanType, short: "s" },
nostr: { type: booleanType, short: "n" },
reddit: { type: booleanType, short: "r" },
mcp: { type: booleanType },
file: { type: stringType },
image: { type: stringType },
Expand Down Expand Up @@ -104,6 +106,7 @@ if (
!flags.telegram &&
!flags.slack &&
!flags.nostr &&
!flags.reddit &&
!flags.mcp)
) {
console.log('Usage: crosspost [options] ["Message to post."]');
Expand All @@ -117,6 +120,7 @@ if (
console.log("--telegram Post to Telegram.");
console.log("--slack, -s Post to Slack.");
console.log("--nostr, -n Post to Nostr.");
console.log("--reddit, -r Post to Reddit.");
console.log("--mcp Start MCP server.");
console.log("--file The file to read the message from.");
console.log("--image The image file to upload with the message.");
Expand Down Expand Up @@ -258,6 +262,15 @@ if (flags.nostr) {
);
}

if (flags.reddit) {
strategies.push(
new RedditStrategy({
accessToken: env.require("REDDIT_ACCESS_TOKEN"),
subreddit: env.require("REDDIT_SUBREDDIT"),
}),
);
}

//-----------------------------------------------------------------------------
// Main
//-----------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,10 @@ export {
NostrEvent,
NostrEventResponse,
} from "./strategies/nostr.js";
export {
RedditStrategy,
RedditOptions,
RedditErrorEntry,
RedditSubmitResponse,
} from "./strategies/reddit.js";
export { Client, ClientOptions, Strategy } from "./client.js";
199 changes: 199 additions & 0 deletions src/strategies/reddit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* @fileoverview Reddit strategy for posting messages.
* @author Nicholas C. Zakas
*/

/* global fetch, URLSearchParams */

//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------

import { validatePostOptions } from "../util/options.js";

//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------

/** @typedef {import("../types.js").PostOptions} PostOptions */

/**
* @typedef {Object} RedditOptions
* @property {string} accessToken The OAuth access token for the Reddit API.
* @property {string} subreddit The subreddit to post to (without the `r/` prefix).
*/

/** @typedef {[string, string, string]} RedditErrorEntry */

/**
* @typedef {Object} RedditSubmitResponse
* @property {Object} json The response body from Reddit.
* @property {Array<RedditErrorEntry>} json.errors Any API validation errors.
* @property {Object} [json.data] Information about the submitted post.
* @property {string} [json.data.url] The canonical URL of the submitted post.
* @property {string} [json.data.permalink] The permalink path for the submitted post.
*/

//-----------------------------------------------------------------------------
// Constants
//-----------------------------------------------------------------------------

const API_BASE = "https://oauth.reddit.com";

//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------

/**
* Formats Reddit API errors into a single string.
* @param {Array<RedditErrorEntry>} errors The Reddit API errors.
* @returns {string} The formatted error message.
*/
function formatErrors(errors) {
return errors.map(error => error.filter(Boolean).join(": ")).join("\n");
}

//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------

/**
* A strategy for posting messages to Reddit.
*/
export class RedditStrategy {
/**
* The ID of the strategy.
* @type {string}
* @readonly
*/
id = "reddit";

/**
* The display name of the strategy.
* @type {string}
* @readonly
*/
name = "Reddit";

/**
* Maximum length of a Reddit self-post message in characters.
* @type {number}
* @const
*/
MAX_MESSAGE_LENGTH = 40300;

/**
* Options for this instance.
* @type {RedditOptions}
*/
#options;

/**
* Creates a new instance.
* @param {RedditOptions} options Options for the instance.
* @throws {Error} When options are missing.
*/
constructor(options) {
const { accessToken, subreddit } = options;

if (!accessToken) {
throw new TypeError("Missing access token.");
}

if (!subreddit) {
throw new TypeError("Missing subreddit.");
}

this.#options = options;
}

/**
* Posts a message to Reddit as a self post.
* The first line of the message is used as the title.
* Remaining lines are used as the post body.
* @param {string} message The message to post.
* @param {PostOptions} [postOptions] Additional options for the post.
* @returns {Promise<RedditSubmitResponse>} A promise that resolves with the Reddit API response.
*/
async post(message, postOptions) {
if (!message) {
throw new TypeError("Missing message to post.");
}

validatePostOptions(postOptions);

if (postOptions?.images?.length) {
throw new Error("Images are not supported in Reddit text posts.");
}

const [firstLine, ...remainingLines] = message.split(/\r?\n/g);
const title = firstLine.trim();
const text = remainingLines.join("\n").trim();
const body = new URLSearchParams({
api_type: "json",
kind: "self",
sr: this.#options.subreddit,
title,
text,
resubmit: "true",
});

const response = await fetch(`${API_BASE}/api/submit`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.#options.accessToken}`,
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent":
"Crosspost (https://github.com/humanwhocodes/crosspost, v1.0.4)", // x-release-please-version
},
body,
signal: postOptions?.signal,
});

const result = /** @type {RedditSubmitResponse} */ (await response.json());
const errors = result.json?.errors ?? [];

if (!response.ok) {
const errorMessage = errors.length
? formatErrors(errors)
: "Unknown Reddit API error.";
throw new Error(
`${response.status} Failed to submit post: ${response.statusText}\n${errorMessage}`,
);
}

if (errors.length) {
throw new Error(`Failed to submit post:\n${formatErrors(errors)}`);
}

return result;
}

/**
* Extracts a URL from a Reddit API response.
* @param {RedditSubmitResponse} response The response from the Reddit API.
* @returns {string} The URL of the Reddit post.
*/
getUrlFromResponse(response) {
const { url, permalink } = response?.json?.data ?? {};
const postUrl = url ?? permalink;

if (!postUrl) {
throw new Error("Post URL not found in response");
}

return postUrl.startsWith("http")
? postUrl
: `https://reddit.com${postUrl}`;
}

/**
* Calculates the length of a message according to Reddit's algorithm.
* All Unicode characters are counted as is.
* @param {string} message The message to calculate the length of.
* @returns {number} The calculated length of the message.
*/
calculateMessageLength(message) {
return [...message].length;
}
}
Loading