generated from nhcarrigan/template
### Explanation We can automate the heck out of announcements everywhere. Except LinkedIn, because that API looks agonising. ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] 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 _No response_ Reviewed-on: #6 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #6.
This commit is contained in:
Generated
+95
@@ -112,6 +112,12 @@ importers:
|
||||
|
||||
server:
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk':
|
||||
specifier: 0.56.0
|
||||
version: 0.56.0
|
||||
'@atproto/api':
|
||||
specifier: 0.15.26
|
||||
version: 0.15.26
|
||||
'@fastify/cors':
|
||||
specifier: 11.0.1
|
||||
version: 11.0.1
|
||||
@@ -127,6 +133,9 @@ importers:
|
||||
gray-matter:
|
||||
specifier: 4.0.3
|
||||
version: 4.0.3
|
||||
twitter-api-v2:
|
||||
specifier: 1.24.0
|
||||
version: 1.24.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 24.0.10
|
||||
@@ -286,6 +295,21 @@ packages:
|
||||
resolution: {integrity: sha512-SLCB8M8+VMg1cpCucnA1XWHGWqVSZtIWzmOdDOEu3eTFZMB+A0sGZ1ESO5MHDnqrNTXz3safMrWx9x4rMZSOqA==}
|
||||
hasBin: true
|
||||
|
||||
'@atproto/api@0.15.26':
|
||||
resolution: {integrity: sha512-AdXGjeCpLZiP9YMGi4YOdK1ayqkBhklmGfSG8UefqR6tTHth59PZvYs5KiwLnFhedt2Xljt3eUlhkn14Y48wEA==}
|
||||
|
||||
'@atproto/common-web@0.4.2':
|
||||
resolution: {integrity: sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==}
|
||||
|
||||
'@atproto/lexicon@0.4.12':
|
||||
resolution: {integrity: sha512-fcEvEQ1GpQYF5igZ4IZjPWEoWVpsEF22L9RexxLS3ptfySXLflEyH384e7HITzO/73McDeaJx3lqHIuqn9ulnw==}
|
||||
|
||||
'@atproto/syntax@0.4.0':
|
||||
resolution: {integrity: sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==}
|
||||
|
||||
'@atproto/xrpc@0.7.1':
|
||||
resolution: {integrity: sha512-ANHEzlskYlMEdH18m+Itp3a8d0pEJao2qoDybDoMupTnoeNkya4VKIaOgAi6ERQnqatBBZyn9asW+7rJmSt/8g==}
|
||||
|
||||
'@babel/code-frame@7.27.1':
|
||||
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1904,6 +1928,9 @@ packages:
|
||||
avvio@9.1.0:
|
||||
resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
|
||||
|
||||
await-lock@2.2.2:
|
||||
resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
@@ -3208,6 +3235,9 @@ packages:
|
||||
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
iso-datestring-validator@2.2.2:
|
||||
resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3600,6 +3630,9 @@ packages:
|
||||
msgpackr@1.11.4:
|
||||
resolution: {integrity: sha512-uaff7RG9VIC4jacFW9xzL3jc0iM32DNHe4jYVycBcjUePT/Klnfj7pqtWJt9khvDFizmjN2TlYniYmSS2LIaZg==}
|
||||
|
||||
multiformats@9.9.0:
|
||||
resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==}
|
||||
|
||||
mute-stream@1.0.0:
|
||||
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
@@ -4467,6 +4500,10 @@ packages:
|
||||
resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tlds@1.259.0:
|
||||
resolution: {integrity: sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==}
|
||||
hasBin: true
|
||||
|
||||
tmp@0.0.33:
|
||||
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
|
||||
engines: {node: '>=0.6.0'}
|
||||
@@ -4555,6 +4592,9 @@ packages:
|
||||
resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==}
|
||||
hasBin: true
|
||||
|
||||
twitter-api-v2@1.24.0:
|
||||
resolution: {integrity: sha512-RDEiuNwnFirvf4c5f1sysgg0rfMQgekXgKt+/UdbNu+Bs5bJ1VbXkqKzdd2a2lPMlDVDbdGUoe2pOd4n25fFVQ==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4607,6 +4647,9 @@ packages:
|
||||
ufo@1.6.1:
|
||||
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
|
||||
|
||||
uint8arrays@3.0.0:
|
||||
resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==}
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4922,6 +4965,9 @@ packages:
|
||||
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zone.js@0.15.1:
|
||||
resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==}
|
||||
|
||||
@@ -5103,6 +5149,39 @@ snapshots:
|
||||
|
||||
'@anthropic-ai/sdk@0.56.0': {}
|
||||
|
||||
'@atproto/api@0.15.26':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.4.2
|
||||
'@atproto/lexicon': 0.4.12
|
||||
'@atproto/syntax': 0.4.0
|
||||
'@atproto/xrpc': 0.7.1
|
||||
await-lock: 2.2.2
|
||||
multiformats: 9.9.0
|
||||
tlds: 1.259.0
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/common-web@0.4.2':
|
||||
dependencies:
|
||||
graphemer: 1.4.0
|
||||
multiformats: 9.9.0
|
||||
uint8arrays: 3.0.0
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/lexicon@0.4.12':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.4.2
|
||||
'@atproto/syntax': 0.4.0
|
||||
iso-datestring-validator: 2.2.2
|
||||
multiformats: 9.9.0
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/syntax@0.4.0': {}
|
||||
|
||||
'@atproto/xrpc@0.7.1':
|
||||
dependencies:
|
||||
'@atproto/lexicon': 0.4.12
|
||||
zod: 3.25.76
|
||||
|
||||
'@babel/code-frame@7.27.1':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.27.1
|
||||
@@ -6794,6 +6873,8 @@ snapshots:
|
||||
'@fastify/error': 4.2.0
|
||||
fastq: 1.19.1
|
||||
|
||||
await-lock@2.2.2: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
base64id@2.0.0: {}
|
||||
@@ -8410,6 +8491,8 @@ snapshots:
|
||||
|
||||
isexe@3.1.1: {}
|
||||
|
||||
iso-datestring-validator@2.2.2: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-instrument@5.2.1:
|
||||
@@ -8909,6 +8992,8 @@ snapshots:
|
||||
msgpackr-extract: 3.0.3
|
||||
optional: true
|
||||
|
||||
multiformats@9.9.0: {}
|
||||
|
||||
mute-stream@1.0.0: {}
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
@@ -9938,6 +10023,8 @@ snapshots:
|
||||
|
||||
tinyspy@4.0.3: {}
|
||||
|
||||
tlds@1.259.0: {}
|
||||
|
||||
tmp@0.0.33:
|
||||
dependencies:
|
||||
os-tmpdir: 1.0.2
|
||||
@@ -10016,6 +10103,8 @@ snapshots:
|
||||
turbo-windows-64: 2.5.4
|
||||
turbo-windows-arm64: 2.5.4
|
||||
|
||||
twitter-api-v2@1.24.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -10073,6 +10162,10 @@ snapshots:
|
||||
ufo@1.6.1:
|
||||
optional: true
|
||||
|
||||
uint8arrays@3.0.0:
|
||||
dependencies:
|
||||
multiformats: 9.9.0
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -10384,4 +10477,6 @@ snapshots:
|
||||
|
||||
yoctocolors-cjs@2.1.2: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zone.js@0.15.1: {}
|
||||
|
||||
+4
-1
@@ -16,11 +16,14 @@
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.56.0",
|
||||
"@atproto/api": "0.15.26",
|
||||
"@fastify/cors": "11.0.1",
|
||||
"@nhcarrigan/logger": "1.0.0",
|
||||
"@prisma/client": "6.11.1",
|
||||
"fastify": "5.4.0",
|
||||
"gray-matter": "4.0.3"
|
||||
"gray-matter": "4.0.3",
|
||||
"twitter-api-v2": "1.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.0.10",
|
||||
|
||||
+12
-1
@@ -2,4 +2,15 @@ LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||
MONGO_URI="op://Environment Variables - Naomi/Hikari/mongo_uri"
|
||||
DISCORD_TOKEN="op://Environment Variables - Naomi/Hikari/discord_token"
|
||||
FORUM_API_KEY="op://Environment Variables - Naomi/Hikari/discourse_key"
|
||||
ANNOUNCEMENT_TOKEN="op://Environment Variables - Naomi/Hikari/announcement_token"
|
||||
ANNOUNCEMENT_TOKEN="op://Environment Variables - Naomi/Hikari/announcement_token"
|
||||
REDDIT_CLIENT_ID="op://Environment Variables - Naomi/Hikari/reddit_client_id"
|
||||
REDDIT_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/reddit_client_secret"
|
||||
REDDIT_PASSWORD="op://Environment Variables - Naomi/Hikari/reddit_password"
|
||||
REDDIT_USERNAME="op://Environment Variables - Naomi/Hikari/reddit_username"
|
||||
BSKY_APP_PASSWORD="op://Environment Variables - Naomi/Hikari/bsky_password"
|
||||
ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key"
|
||||
TWITTER_TOKEN="op://Environment Variables - Naomi/Hikari/twitter_access_token"
|
||||
TWITTER_SECRET="op://Environment Variables - Naomi/Hikari/twitter_access_secret"
|
||||
TWITTER_CONSUMER_KEY="op://Environment Variables - Naomi/Hikari/twitter_consumer_key"
|
||||
TWITTER_CONSUMER_SECRET="op://Environment Variables - Naomi/Hikari/twitter_consumer_secret"
|
||||
TWITTER_BEARER_TOKEN="op://Environment Variables - Naomi/Hikari/twitter_bearer_token"
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { AtpAgent } from "@atproto/api";
|
||||
|
||||
/**
|
||||
* Forwards an announcement to our Bluesky account.
|
||||
* @param content - The main body of the announcement.
|
||||
* @returns A message indicating the success or failure of the operation.
|
||||
*/
|
||||
export const announceOnBluesky = async(
|
||||
content: string,
|
||||
): Promise<string> => {
|
||||
if (process.env.BSKY_APP_PASSWORD === undefined) {
|
||||
return "Bluesky credentials are not set.";
|
||||
}
|
||||
const agent = new AtpAgent({
|
||||
service: "https://bsky.social",
|
||||
});
|
||||
await agent.login({
|
||||
identifier: "nhcarrigan.com",
|
||||
password: process.env.BSKY_APP_PASSWORD,
|
||||
});
|
||||
const blueskyRequest = await agent.post({
|
||||
text: content,
|
||||
}).catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof blueskyRequest === "string") {
|
||||
return `Failed to send message to Bluesky. ${blueskyRequest}`;
|
||||
}
|
||||
return "Successfully sent message to Bluesky.";
|
||||
};
|
||||
@@ -41,12 +41,12 @@ export const announceOnDiscord = async(
|
||||
},
|
||||
);
|
||||
if (messageRequest.status !== 200) {
|
||||
return "Failed to send message to Discord.";
|
||||
return `Failed to send message to Discord. Status: ${messageRequest.status.toString()} ${messageRequest.statusText}`;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- fetch does not accept generics.
|
||||
const message = await messageRequest.json() as { id?: string };
|
||||
if (message.id === undefined) {
|
||||
return "Failed to parse message ID, cannot crosspost.";
|
||||
return `Failed to parse message ID, cannot crosspost. ${JSON.stringify(message)}`;
|
||||
}
|
||||
const crosspostRequest = await fetch(
|
||||
`https://discord.com/api/v10/channels/${channelIds[type]}/messages/${message.id}/crosspost`,
|
||||
@@ -59,7 +59,7 @@ export const announceOnDiscord = async(
|
||||
},
|
||||
);
|
||||
if (!crosspostRequest.ok) {
|
||||
return "Failed to crosspost message to Discord.";
|
||||
return `Failed to crosspost message to Discord. Status: ${crosspostRequest.status.toString()} ${crosspostRequest.statusText}`;
|
||||
}
|
||||
return "Successfully sent and published message to Discord.";
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ export const announceOnForum = async(
|
||||
},
|
||||
);
|
||||
if (forumRequest.status !== 200) {
|
||||
return "Failed to send message to forum.";
|
||||
return `Failed to send message to forum. Status: ${forumRequest.status.toString()} ${forumRequest.statusText}`;
|
||||
}
|
||||
return "Successfully sent message to forum.";
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- we are making raw API calls. */
|
||||
/* eslint-disable max-lines-per-function -- Big logic here. */
|
||||
|
||||
const flairIds = {
|
||||
community: "7a01a5a6-0f29-11ef-a0c4-c6fb085f7c8f",
|
||||
products: "335e57b6-083f-11ef-96b3-0202af2d9d99",
|
||||
};
|
||||
|
||||
/**
|
||||
* Posts an announcement to a specific subreddit as a self-post.
|
||||
* @param title - The title of the announcement.
|
||||
* @param content - The main body of the announcement.
|
||||
* @param type - Whether the announcement is for a product or community.
|
||||
* @returns A message indicating the success or failure of the operation.
|
||||
*/
|
||||
export const announceOnReddit = async(
|
||||
title: string,
|
||||
content: string,
|
||||
type: "products" | "community",
|
||||
): Promise<string> => {
|
||||
if (
|
||||
process.env.REDDIT_CLIENT_ID === undefined
|
||||
|| process.env.REDDIT_CLIENT_SECRET === undefined
|
||||
|| process.env.REDDIT_USERNAME === undefined
|
||||
|| process.env.REDDIT_PASSWORD === undefined
|
||||
) {
|
||||
return "Reddit credentials are not set.";
|
||||
}
|
||||
const tokenResponse = await fetch(
|
||||
"https://www.reddit.com/api/v1/access_token",
|
||||
{
|
||||
body: new URLSearchParams({
|
||||
grant_type: "password",
|
||||
password: process.env.REDDIT_PASSWORD,
|
||||
username: process.env.REDDIT_USERNAME,
|
||||
}),
|
||||
headers: {
|
||||
"Authorization": `Basic ${Buffer.from(
|
||||
`${process.env.REDDIT_CLIENT_ID}:${process.env.REDDIT_CLIENT_SECRET}`,
|
||||
).toString("base64")}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "HikariBot/1.0 by nhcarrigan",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (tokenResponse.status !== 200) {
|
||||
return `Failed to obtain Reddit access token. Status: ${tokenResponse.status.toString()} ${tokenResponse.statusText}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Fetch does not accept generic.
|
||||
const tokenData = (await tokenResponse.json()) as { access_token?: string };
|
||||
|
||||
if (tokenData.access_token === undefined) {
|
||||
return `Failed to obtain Reddit access token. ${JSON.stringify(tokenData)}`;
|
||||
}
|
||||
|
||||
const redditPost = await fetch("https://oauth.reddit.com/api/submit", {
|
||||
body: new URLSearchParams({
|
||||
api_type: "json",
|
||||
flair_id: flairIds[type],
|
||||
flair_text: type,
|
||||
kind: "self",
|
||||
sr: "nhcarrigan",
|
||||
text: content,
|
||||
title: title,
|
||||
}),
|
||||
headers: {
|
||||
"Authorization": `bearer ${tokenData.access_token}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "HikariBot/1.0 by nhcarrigan",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Fetch does not accept generic.
|
||||
const redditData = (await redditPost.json()) as {
|
||||
json: {
|
||||
errors: Array<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
if (redditData.json.errors.length > 0) {
|
||||
return `Failed to post to Reddit: ${JSON.stringify(
|
||||
redditData.json.errors,
|
||||
)}`;
|
||||
}
|
||||
|
||||
return "Successfully posted announcement to Reddit~! ✨";
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { TwitterApi } from "twitter-api-v2";
|
||||
|
||||
/**
|
||||
* Forwards an announcement to our Twitter account.
|
||||
* @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
|
||||
|| 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,
|
||||
});
|
||||
|
||||
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.";
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @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;
|
||||
};
|
||||
@@ -6,9 +6,13 @@
|
||||
|
||||
import { blockedIps } from "../cache/blockedIps.js";
|
||||
import { database } from "../db/database.js";
|
||||
import { announceOnBluesky } from "../modules/announceOnBluesky.js";
|
||||
import { announceOnDiscord } from "../modules/announceOnDiscord.js";
|
||||
import { announceOnForum } from "../modules/announceOnForum.js";
|
||||
import { announceOnReddit } from "../modules/announceOnReddit.js";
|
||||
import { announceOnTwitter } from "../modules/announceOnTwitter.js";
|
||||
import { getIpFromRequest } from "../modules/getIpFromRequest.js";
|
||||
import { summarisePost } from "../modules/summarisePost.js";
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
@@ -41,7 +45,7 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify requires Body instead of body.
|
||||
server.post<{ Body: { title: string; content: string; type: string } }>(
|
||||
"/announcement",
|
||||
// eslint-disable-next-line complexity -- This is a complex route, but it is necessary to validate the announcement.
|
||||
// eslint-disable-next-line complexity, max-statements -- This is a complex route, but it is necessary to validate the announcement.
|
||||
async(request, reply) => {
|
||||
const token = request.headers.authorization;
|
||||
if (token === undefined || token !== process.env.ANNOUNCEMENT_TOKEN) {
|
||||
@@ -102,8 +106,23 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => {
|
||||
|
||||
const discord = await announceOnDiscord(title, content, type);
|
||||
const forum = await announceOnForum(title, content, type);
|
||||
const reddit = await announceOnReddit(title, content, type);
|
||||
const summary = await summarisePost(title, content);
|
||||
if (summary === null) {
|
||||
return await reply.status(201).send({
|
||||
message: `Announcement processed. Discord: ${discord}, Forum: ${forum}, Reddit: ${reddit}, Bluesky: Skipped (AI summarisation failed), Twitter: Skipped (AI summarisation failed).`,
|
||||
});
|
||||
}
|
||||
if (summary.length > 280) {
|
||||
return await reply.status(201).send({
|
||||
message: `Announcement processed. Discord: ${discord}, Forum: ${forum}, Reddit: ${reddit}, Bluesky: Skipped (AI summary too long), Twitter: Skipped (AI summary too long).`,
|
||||
});
|
||||
}
|
||||
|
||||
const bluesky = await announceOnBluesky(summary);
|
||||
const twitter = await announceOnTwitter(summary);
|
||||
return await reply.status(201).send({
|
||||
message: `Announcement processed. Discord: ${discord}, Forum: ${forum}`,
|
||||
message: `Announcement processed. Discord: ${discord}, Forum: ${forum}, Reddit: ${reddit}, Bluesky: ${bluesky}, Twitter: ${twitter}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user