generated from nhcarrigan/template
feat: more automated announcements (#8)
### Explanation Makes my life so much easier. ### Issue _No response_ ### Attestations - [x] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [x] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [x] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [x] I have pinned the dependencies to a specific patch version. ### Style - [x] I have run the linter and resolved any errors. - [x] My pull request uses an appropriate title, matching the conventional commit standards. - [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning Minor - My pull request introduces a new non-breaking feature. Reviewed-on: #8 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #8.
This commit is contained in:
@@ -11,12 +11,18 @@ import { AtpAgent } from "@atproto/api";
|
||||
* @param content - The main body of the announcement.
|
||||
* @returns A message indicating the success or failure of the operation.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function, max-statements -- This is a big function.
|
||||
export const announceOnBluesky = async(
|
||||
content: string,
|
||||
content: Array<string>,
|
||||
): Promise<string> => {
|
||||
if (process.env.BSKY_APP_PASSWORD === undefined) {
|
||||
return "Bluesky credentials are not set.";
|
||||
}
|
||||
const [ firstPost, ...restOfPosts ] = content;
|
||||
const failedReplies: Array<string> = [];
|
||||
if (firstPost === undefined) {
|
||||
return "No posts to send to Bluesky.";
|
||||
}
|
||||
const agent = new AtpAgent({
|
||||
service: "https://bsky.social",
|
||||
});
|
||||
@@ -25,14 +31,46 @@ export const announceOnBluesky = async(
|
||||
password: process.env.BSKY_APP_PASSWORD,
|
||||
});
|
||||
const blueskyRequest = await agent.post({
|
||||
text: content,
|
||||
text: firstPost,
|
||||
}).catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof blueskyRequest === "string") {
|
||||
return `Failed to send message to Bluesky. ${blueskyRequest}`;
|
||||
return `Failed to send initial post to Bluesky. ${blueskyRequest}`;
|
||||
}
|
||||
return "Successfully sent message to Bluesky.";
|
||||
const rootUri = blueskyRequest.uri;
|
||||
const rootCid = blueskyRequest.cid;
|
||||
let parentUri = rootUri;
|
||||
let parentCid = rootCid;
|
||||
for (const post of restOfPosts) {
|
||||
// eslint-disable-next-line no-await-in-loop -- We need to do this sequentially.
|
||||
const blueskyResponse = await agent.post({
|
||||
reply: {
|
||||
parent: {
|
||||
cid: parentCid,
|
||||
uri: parentUri,
|
||||
},
|
||||
root: {
|
||||
cid: rootCid,
|
||||
uri: rootUri,
|
||||
},
|
||||
},
|
||||
text: post,
|
||||
}).catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof blueskyResponse === "string") {
|
||||
failedReplies.push(post);
|
||||
continue;
|
||||
}
|
||||
parentUri = blueskyResponse.uri;
|
||||
parentCid = blueskyResponse.cid;
|
||||
}
|
||||
return `Successfully sent initial post to Bluesky. ${failedReplies.length > 0
|
||||
? `Failed to send ${failedReplies.length.toString()} replies: ${failedReplies.join(", ")}`
|
||||
: `All ${(content.length - 1).toString()} replies were sent successfully.`}`;
|
||||
};
|
||||
|
||||
@@ -5,14 +5,23 @@
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- we are making raw API calls. */
|
||||
|
||||
const channelIds = {
|
||||
import type { AnnouncementType } from "../interfaces/announcementType.js";
|
||||
|
||||
const channelIds: Record<AnnouncementType, string> = {
|
||||
community: "1386105484313886820",
|
||||
company: "1422472775695728661",
|
||||
products: "1386105452881776661",
|
||||
} as const;
|
||||
const roleIds = {
|
||||
};
|
||||
const roleIds: Record<Exclude<AnnouncementType, "company">, string> = {
|
||||
community: "1386107941224054895",
|
||||
products: "1386107909699666121",
|
||||
} as const;
|
||||
};
|
||||
|
||||
const getAnnouncementPing = (type: AnnouncementType): string => {
|
||||
return type === "company"
|
||||
? "@everyone"
|
||||
: `<@&${roleIds[type]}>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Forwards an announcement to our Discord server.
|
||||
@@ -24,14 +33,14 @@ const roleIds = {
|
||||
export const announceOnDiscord = async(
|
||||
title: string,
|
||||
content: string,
|
||||
type: "products" | "community",
|
||||
type: AnnouncementType,
|
||||
): Promise<string> => {
|
||||
const messageRequest = await fetch(
|
||||
`https://discord.com/api/v10/channels/${channelIds[type]}/messages`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
allowed_mentions: { parse: [ "users", "roles" ] },
|
||||
content: `# ${title}\n\n${content}\n-# <@&${roleIds[type]}>`,
|
||||
content: `# ${title}\n\n${content}\n-# ${getAnnouncementPing(type)}`,
|
||||
}),
|
||||
headers: {
|
||||
"Authorization": `Bot ${process.env.DISCORD_TOKEN ?? ""}`,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
interface FacebookErrorResponse {
|
||||
error: {
|
||||
code: number;
|
||||
message: string;
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface FacebookSuccessResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
type FacebookResponse = FacebookErrorResponse | FacebookSuccessResponse;
|
||||
|
||||
/**
|
||||
* Forwards an announcement to our Facebook Page.
|
||||
* @param content - The main body of the announcement.
|
||||
* @returns A message indicating the success or failure of the operation.
|
||||
*/
|
||||
export const announceOnFacebook = async(content: string): Promise<string> => {
|
||||
if (
|
||||
process.env.FACEBOOK_PAGE_TOKEN === undefined
|
||||
|| process.env.FACEBOOK_PAGE_ID === undefined
|
||||
) {
|
||||
return "Facebook credentials are not set.";
|
||||
}
|
||||
|
||||
if (content.trim().length === 0) {
|
||||
return "No content to send to Facebook.";
|
||||
}
|
||||
|
||||
const pageId = process.env.FACEBOOK_PAGE_ID;
|
||||
const accessToken = process.env.FACEBOOK_PAGE_TOKEN;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://graph.facebook.com/v21.0/${pageId}/feed`,
|
||||
{
|
||||
body: new URLSearchParams({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Facebook API requires snake_case.
|
||||
access_token: accessToken,
|
||||
message: content,
|
||||
}),
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Fetch does not accept generic.
|
||||
const result = (await response.json()) as FacebookResponse;
|
||||
|
||||
if ("error" in result) {
|
||||
const errorMessage = result.error.message === ""
|
||||
? JSON.stringify(result.error)
|
||||
: result.error.message;
|
||||
return `Failed to send message to Facebook. ${errorMessage}`;
|
||||
}
|
||||
|
||||
if ("id" in result) {
|
||||
return `Successfully sent post to Facebook. Post ID: ${result.id}`;
|
||||
}
|
||||
|
||||
return `Failed to send message to Facebook. Unexpected response: ${JSON.stringify(result)}`;
|
||||
} catch (error: unknown) {
|
||||
return `Failed to send message to Facebook. ${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
}`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { isValidString } from "../utils/typeguards.js";
|
||||
|
||||
/**
|
||||
* Forwards an announcement to our Mastodon account.
|
||||
* @param content - The main body of the announcement.
|
||||
* @returns A message indicating the success or failure of the operation.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function, max-statements, complexity -- This is a big function.
|
||||
export const announceOnMastodon = async(
|
||||
content: Array<string>,
|
||||
): Promise<string> => {
|
||||
if (
|
||||
process.env.MASTODON_INSTANCE_URL === undefined
|
||||
|| process.env.MASTODON_ACCESS_TOKEN === undefined
|
||||
) {
|
||||
return "Mastodon credentials are not set.";
|
||||
}
|
||||
const [ firstPost, ...restOfPosts ] = content;
|
||||
const failedReplies: Array<string> = [];
|
||||
if (firstPost === undefined) {
|
||||
return "No posts to send to Mastodon.";
|
||||
}
|
||||
const instanceUrl = process.env.MASTODON_INSTANCE_URL.replace(/\/$/, "");
|
||||
const accessToken = process.env.MASTODON_ACCESS_TOKEN;
|
||||
const apiUrl = `${instanceUrl}/api/v1/statuses`;
|
||||
const headers = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const firstPostResponse = await fetch(apiUrl, {
|
||||
body: JSON.stringify({ status: firstPost }),
|
||||
headers: headers,
|
||||
method: "POST",
|
||||
}).catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof firstPostResponse === "string") {
|
||||
return `Failed to send initial post to Mastodon. ${firstPostResponse}`;
|
||||
}
|
||||
if (!firstPostResponse.ok) {
|
||||
const errorText = await firstPostResponse.text().catch(() => {
|
||||
return firstPostResponse.statusText;
|
||||
});
|
||||
return `Failed to send initial post to Mastodon. Status: ${firstPostResponse.status.toString()} ${errorText}`;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Fetch does not accept generics.
|
||||
const firstPostData = await firstPostResponse.json() as { id?: string };
|
||||
if (firstPostData.id === undefined) {
|
||||
return `Failed to parse initial post ID from Mastodon. ${JSON.stringify(firstPostData)}`;
|
||||
}
|
||||
let inReplyToId = firstPostData.id;
|
||||
for (const post of restOfPosts) {
|
||||
// eslint-disable-next-line no-await-in-loop -- We need to do this sequentially.
|
||||
const replyResponse = await fetch(apiUrl, {
|
||||
body: JSON.stringify({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement.
|
||||
in_reply_to_id: inReplyToId,
|
||||
status: post,
|
||||
}),
|
||||
headers: headers,
|
||||
method: "POST",
|
||||
}).catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof replyResponse === "string") {
|
||||
failedReplies.push(post);
|
||||
continue;
|
||||
}
|
||||
if (!replyResponse.ok) {
|
||||
failedReplies.push(post);
|
||||
continue;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-await-in-loop -- Fetch does not accept generics.
|
||||
const replyData = await replyResponse.json() as { id?: string };
|
||||
if (isValidString(replyData.id)) {
|
||||
inReplyToId = replyData.id;
|
||||
continue;
|
||||
}
|
||||
failedReplies.push(post);
|
||||
}
|
||||
return `Successfully sent initial post to Mastodon. ${failedReplies.length > 0
|
||||
? `Failed to send ${failedReplies.length.toString()} replies: ${failedReplies.join(", ")}`
|
||||
: `All ${(content.length - 1).toString()} replies were sent successfully.`}`;
|
||||
};
|
||||
@@ -6,8 +6,11 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- we are making raw API calls. */
|
||||
/* eslint-disable max-lines-per-function -- Big logic here. */
|
||||
|
||||
const flairIds = {
|
||||
import type { AnnouncementType } from "../interfaces/announcementType.js";
|
||||
|
||||
const flairIds: Record<AnnouncementType, string> = {
|
||||
community: "7a01a5a6-0f29-11ef-a0c4-c6fb085f7c8f",
|
||||
company: "dd8057c0-9e30-11f0-b321-d683551dcb2b",
|
||||
products: "335e57b6-083f-11ef-96b3-0202af2d9d99",
|
||||
};
|
||||
|
||||
@@ -21,7 +24,7 @@ const flairIds = {
|
||||
export const announceOnReddit = async(
|
||||
title: string,
|
||||
content: string,
|
||||
type: "products" | "community",
|
||||
type: AnnouncementType,
|
||||
): Promise<string> => {
|
||||
if (
|
||||
process.env.REDDIT_CLIENT_ID === undefined
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { isValidString } from "../utils/typeguards.js";
|
||||
|
||||
interface ThreadsErrorResponse {
|
||||
error: {
|
||||
message: string;
|
||||
type: string;
|
||||
code: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ThreadsSuccessResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
type ThreadsResponse = ThreadsErrorResponse | ThreadsSuccessResponse;
|
||||
|
||||
/**
|
||||
* Forwards an announcement to our Threads account.
|
||||
* @param content - The main body of the announcement.
|
||||
* @returns A message indicating the success or failure of the operation.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function, max-statements, complexity -- This is a big function.
|
||||
export const announceOnThreads = async(
|
||||
content: Array<string>,
|
||||
): Promise<string> => {
|
||||
if (
|
||||
process.env.THREADS_ACCESS_TOKEN === undefined
|
||||
) {
|
||||
return "Threads credentials are not set.";
|
||||
}
|
||||
const [ firstPost, ...restOfPosts ] = content;
|
||||
const failedReplies: Array<string> = [];
|
||||
if (firstPost === undefined) {
|
||||
return "No posts to send to Threads.";
|
||||
}
|
||||
const accessToken = process.env.THREADS_ACCESS_TOKEN;
|
||||
const apiUrl = `https://graph.threads.net/v1.0/me/threads`;
|
||||
// Step 1: Create the first post
|
||||
const firstPostParameters = new URLSearchParams({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement.
|
||||
access_token: accessToken,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement.
|
||||
media_type: "TEXT",
|
||||
text: firstPost,
|
||||
});
|
||||
const firstPostResponse = await fetch(
|
||||
`${apiUrl}?${firstPostParameters.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
).catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof firstPostResponse === "string") {
|
||||
return `Failed to send initial post to Threads. ${firstPostResponse}`;
|
||||
}
|
||||
if (!firstPostResponse.ok) {
|
||||
const errorText = await firstPostResponse.text().catch(() => {
|
||||
return firstPostResponse.statusText;
|
||||
});
|
||||
return `Failed to send initial post to Threads. Status: ${firstPostResponse.status.toString()} ${errorText}`;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Fetch does not accept generics.
|
||||
const firstPostData = await firstPostResponse.json() as ThreadsResponse;
|
||||
if ("error" in firstPostData) {
|
||||
return `Failed to send initial post to Threads. ${firstPostData.error.message}`;
|
||||
}
|
||||
if (!isValidString(firstPostData.id)) {
|
||||
return `Failed to parse initial post ID from Threads. ${JSON.stringify(firstPostData)}`;
|
||||
}
|
||||
// Step 2: Publish the first post
|
||||
const publishUrl = `https://graph.threads.net/v1.0/me/threads_publish`;
|
||||
const publishParameters = new URLSearchParams({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement.
|
||||
access_token: accessToken,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement.
|
||||
creation_id: firstPostData.id,
|
||||
});
|
||||
const publishResponse = await fetch(
|
||||
`${publishUrl}?${publishParameters.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
).catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof publishResponse === "string") {
|
||||
return `Failed to publish initial post to Threads. ${publishResponse}`;
|
||||
}
|
||||
if (!publishResponse.ok) {
|
||||
const errorText = await publishResponse.text().catch(() => {
|
||||
return publishResponse.statusText;
|
||||
});
|
||||
return `Failed to publish initial post to Threads. Status: ${publishResponse.status.toString()} ${errorText}`;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Fetch does not accept generics.
|
||||
const publishData = await publishResponse.json() as ThreadsSuccessResponse;
|
||||
let parentThreadId = publishData.id;
|
||||
// Step 3: Create replies for the rest of the posts
|
||||
for (const post of restOfPosts) {
|
||||
const replyParameters = new URLSearchParams({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement.
|
||||
access_token: accessToken,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement.
|
||||
media_type: "TEXT",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement.
|
||||
reply_to_id: parentThreadId,
|
||||
text: post,
|
||||
});
|
||||
// eslint-disable-next-line no-await-in-loop -- We need to do this sequentially.
|
||||
const replyResponse = await fetch(
|
||||
`${apiUrl}?${replyParameters.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
).catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof replyResponse === "string") {
|
||||
failedReplies.push(post);
|
||||
continue;
|
||||
}
|
||||
if (!replyResponse.ok) {
|
||||
failedReplies.push(post);
|
||||
continue;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-await-in-loop -- Fetch does not accept generics.
|
||||
const replyData = await replyResponse.json() as ThreadsResponse;
|
||||
if ("error" in replyData) {
|
||||
failedReplies.push(post);
|
||||
continue;
|
||||
}
|
||||
if (!isValidString(replyData.id)) {
|
||||
failedReplies.push(post);
|
||||
continue;
|
||||
}
|
||||
// Publish the reply
|
||||
const replyPublishUrl = `https://graph.threads.net/v1.0/me/threads_publish`;
|
||||
const replyPublishParameters = new URLSearchParams({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement.
|
||||
access_token: accessToken,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement.
|
||||
creation_id: replyData.id,
|
||||
});
|
||||
// eslint-disable-next-line no-await-in-loop -- We need to do this sequentially.
|
||||
const replyPublishResponse = await fetch(
|
||||
`${replyPublishUrl}?${replyPublishParameters.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
).catch(() => {
|
||||
return null;
|
||||
});
|
||||
if (replyPublishResponse?.ok !== true) {
|
||||
failedReplies.push(post);
|
||||
continue;
|
||||
}
|
||||
const replyPublishData
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-await-in-loop -- Fetch does not accept generics.
|
||||
= await replyPublishResponse.json() as ThreadsSuccessResponse;
|
||||
parentThreadId = replyPublishData.id;
|
||||
}
|
||||
return `Successfully sent initial post to Threads. ${failedReplies.length > 0
|
||||
? `Failed to send ${failedReplies.length.toString()} replies: ${failedReplies.join(", ")}`
|
||||
: `All ${(content.length - 1).toString()} replies were sent successfully.`}`;
|
||||
};
|
||||
@@ -11,31 +11,50 @@ import { TwitterApi } from "twitter-api-v2";
|
||||
* @param content - The main body of the announcement.
|
||||
* @returns A message indicating the success or failure of the operation.
|
||||
*/
|
||||
export const announceOnTwitter = async(content: string): Promise<string> => {
|
||||
if (
|
||||
process.env.TWITTER_CONSUMER_KEY === undefined
|
||||
export const announceOnTwitter
|
||||
= async(content: Array<string>): Promise<string> => {
|
||||
if (
|
||||
process.env.TWITTER_CONSUMER_KEY === undefined
|
||||
|| process.env.TWITTER_CONSUMER_SECRET === undefined
|
||||
|| process.env.TWITTER_TOKEN === undefined
|
||||
|| process.env.TWITTER_SECRET === undefined
|
||||
) {
|
||||
return "Twitter credentials are not set.";
|
||||
}
|
||||
const twitterClient = new TwitterApi({
|
||||
accessSecret: process.env.TWITTER_SECRET,
|
||||
accessToken: process.env.TWITTER_TOKEN,
|
||||
appKey: process.env.TWITTER_CONSUMER_KEY,
|
||||
appSecret: process.env.TWITTER_CONSUMER_SECRET,
|
||||
});
|
||||
) {
|
||||
return "Twitter credentials are not set.";
|
||||
}
|
||||
const twitterClient = new TwitterApi({
|
||||
accessSecret: process.env.TWITTER_SECRET,
|
||||
accessToken: process.env.TWITTER_TOKEN,
|
||||
appKey: process.env.TWITTER_CONSUMER_KEY,
|
||||
appSecret: process.env.TWITTER_CONSUMER_SECRET,
|
||||
});
|
||||
|
||||
const result = await twitterClient.v2.
|
||||
tweet(content).
|
||||
catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof result === "string") {
|
||||
return `Failed to send message to Twitter. ${result}`;
|
||||
}
|
||||
return "Successfully sent message to Twitter.";
|
||||
};
|
||||
const [ firstPost, ...restOfPosts ] = content;
|
||||
const failedReplies: Array<string> = [];
|
||||
if (firstPost === undefined) {
|
||||
return "No posts to send to Twitter.";
|
||||
}
|
||||
const result = await twitterClient.v2.
|
||||
tweet(firstPost).
|
||||
catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof result === "string") {
|
||||
return `Failed to send message to Twitter. ${result}`;
|
||||
}
|
||||
let { id } = result.data;
|
||||
for (const post of restOfPosts) {
|
||||
// eslint-disable-next-line no-await-in-loop -- We need to do this sequentially.
|
||||
const twitterResponse = await twitterClient.v2.reply(post, id);
|
||||
if (typeof twitterResponse !== "string") {
|
||||
const { id: replyId } = twitterResponse.data;
|
||||
id = replyId;
|
||||
continue;
|
||||
}
|
||||
failedReplies.push(post);
|
||||
}
|
||||
return `Successfully sent initial post to Twitter. ${failedReplies.length > 0
|
||||
? `Failed to send ${failedReplies.length.toString()} replies: ${failedReplies.join(", ")}`
|
||||
: `All ${(content.length - 1).toString()} replies were sent successfully.`}`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- 'Tis a class.
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import {
|
||||
announcementJsonSchema,
|
||||
announcementSystemMessage,
|
||||
} from "../config/announcements.js";
|
||||
import { getAiCost } from "../utils/getAiCost.js";
|
||||
import type { AnnouncementResponse }
|
||||
from "../interfaces/announcementResponse.js";
|
||||
|
||||
/**
|
||||
* Generates announcements for all platforms using AI.
|
||||
* @param content - The main body of the announcement.
|
||||
* @returns The announcements for all platforms, or null if the request fails.
|
||||
*/
|
||||
export const generateAnnouncements = async(
|
||||
content: string,
|
||||
): Promise<{ cost: string; response: AnnouncementResponse } | null> => {
|
||||
if (process.env.ANTHROPIC_KEY === undefined) {
|
||||
return null;
|
||||
}
|
||||
const anthropic = new Anthropic({
|
||||
apiKey: process.env.ANTHROPIC_KEY,
|
||||
timeout: 5 * 60 * 1000,
|
||||
});
|
||||
const response = await anthropic.beta.messages.create({
|
||||
betas: [ "structured-outputs-2025-11-13" ],
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement
|
||||
max_tokens: 10_000,
|
||||
messages: [
|
||||
{
|
||||
content: content,
|
||||
role: "user",
|
||||
},
|
||||
],
|
||||
model: "claude-opus-4-5-20251101",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement
|
||||
output_format: {
|
||||
schema: announcementJsonSchema,
|
||||
type: "json_schema",
|
||||
},
|
||||
system: announcementSystemMessage,
|
||||
});
|
||||
const { usage, content: responseContent } = response;
|
||||
const text = responseContent.find((m) => {
|
||||
return m.type === "text";
|
||||
});
|
||||
if (text?.text === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
cost: getAiCost(usage),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Being lazy.
|
||||
response: JSON.parse(text.text) as AnnouncementResponse,
|
||||
};
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- 'Tis a class.
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
|
||||
/**
|
||||
* Summarises an announcement using AI, to condense the content for platforms like Bluesky and Twitter.
|
||||
* @param title - The title of the announcement.
|
||||
* @param content - The main body of the announcement.
|
||||
* @returns A message indicating the success or failure of the operation.
|
||||
*/
|
||||
export const summarisePost = async(
|
||||
title: string,
|
||||
content: string,
|
||||
): Promise<string | null> => {
|
||||
if (process.env.ANTHROPIC_KEY === undefined) {
|
||||
return null;
|
||||
}
|
||||
const anthropic = new Anthropic({
|
||||
apiKey: process.env.ANTHROPIC_KEY,
|
||||
timeout: 5 * 60 * 1000,
|
||||
});
|
||||
const response = await anthropic.messages.create({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement
|
||||
max_tokens: 1000,
|
||||
messages: [
|
||||
{
|
||||
content: `# ${title}\n\n${content}`,
|
||||
role: "user",
|
||||
},
|
||||
],
|
||||
model: "claude-4-sonnet-20250514",
|
||||
// eslint-disable-next-line stylistic/max-len -- This is a long system message.
|
||||
system: "Summarise the post the user provides into a concise message suitable for social media platforms like Bluesky and Twitter. The summary should be engaging and informative, capturing the essence of the announcement. You may use no more than 280 characters, and should include relevant hashtags if appropriate.",
|
||||
});
|
||||
const text = response.content.find((m) => {
|
||||
return m.type === "text";
|
||||
});
|
||||
return text?.text ?? null;
|
||||
};
|
||||
Reference in New Issue
Block a user