From 5b99450b7c5f12c90430e4dae8e8e54b048eee22 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 12:57:20 -0800 Subject: [PATCH 01/11] feat: define type, prep for company announcements --- server/src/interfaces/announcementType.ts | 10 +++++++ server/src/modules/announceOnDiscord.ts | 21 ++++++++++----- server/src/modules/announceOnReddit.ts | 7 +++-- server/src/routes/announcement.ts | 5 ++-- server/src/routes/sanction.ts | 2 +- server/src/utils/isValidString.ts | 14 ---------- server/src/utils/typeguards.ts | 32 +++++++++++++++++++++++ 7 files changed, 66 insertions(+), 25 deletions(-) create mode 100644 server/src/interfaces/announcementType.ts delete mode 100644 server/src/utils/isValidString.ts create mode 100644 server/src/utils/typeguards.ts diff --git a/server/src/interfaces/announcementType.ts b/server/src/interfaces/announcementType.ts new file mode 100644 index 0000000..432200d --- /dev/null +++ b/server/src/interfaces/announcementType.ts @@ -0,0 +1,10 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/** + * This really only exists so we can do a type guard. + */ +export type AnnouncementType = "products" | "community" | "company"; diff --git a/server/src/modules/announceOnDiscord.ts b/server/src/modules/announceOnDiscord.ts index 5262716..5b886cd 100644 --- a/server/src/modules/announceOnDiscord.ts +++ b/server/src/modules/announceOnDiscord.ts @@ -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 = { community: "1386105484313886820", + company: "1422472775695728661", products: "1386105452881776661", -} as const; -const roleIds = { +}; +const roleIds: Record = { community: "1386107941224054895", - products: "1386107909699666121", -} as const; + + /** + * Note that this is not a role ID, but the server ID. + * Company announcements ping everyone. + */ + company: "1354624415861833870", + products: "1386107909699666121", +}; /** * Forwards an announcement to our Discord server. @@ -24,7 +33,7 @@ const roleIds = { export const announceOnDiscord = async( title: string, content: string, - type: "products" | "community", + type: AnnouncementType, ): Promise => { const messageRequest = await fetch( `https://discord.com/api/v10/channels/${channelIds[type]}/messages`, diff --git a/server/src/modules/announceOnReddit.ts b/server/src/modules/announceOnReddit.ts index f38423d..d1c3a57 100644 --- a/server/src/modules/announceOnReddit.ts +++ b/server/src/modules/announceOnReddit.ts @@ -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 = { 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 => { if ( process.env.REDDIT_CLIENT_ID === undefined diff --git a/server/src/routes/announcement.ts b/server/src/routes/announcement.ts index 6ccfba5..ec4370d 100644 --- a/server/src/routes/announcement.ts +++ b/server/src/routes/announcement.ts @@ -12,6 +12,7 @@ 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 { isAnnouncementType } from "../utils/typeguards.js"; import type { FastifyPluginAsync } from "fastify"; const oneDay = 24 * 60 * 60 * 1000; @@ -73,10 +74,10 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { }); } - if (type !== "products" && type !== "community") { + if (!isAnnouncementType(type)) { return await reply.status(400).send({ error: - "Invalid announcement type. Available types: products, community.", + `Invalid announcement type. Available types: products, community, company.`, }); } diff --git a/server/src/routes/sanction.ts b/server/src/routes/sanction.ts index 29c93ee..3036d03 100644 --- a/server/src/routes/sanction.ts +++ b/server/src/routes/sanction.ts @@ -8,7 +8,7 @@ import { blockedIps } from "../cache/blockedIps.js"; import { database } from "../db/database.js"; import { getIpFromRequest } from "../modules/getIpFromRequest.js"; import { getSanctionComponents } from "../modules/getSanctionComponents.js"; -import { isValidString } from "../utils/isValidString.js"; +import { isValidString } from "../utils/typeguards.js"; import type { FastifyPluginAsync } from "fastify"; const oneDay = 24 * 60 * 60 * 1000; diff --git a/server/src/utils/isValidString.ts b/server/src/utils/isValidString.ts deleted file mode 100644 index 923b6cf..0000000 --- a/server/src/utils/isValidString.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @copyright NHCarrigan - * @license Naomi's Public License - * @author Naomi Carrigan - */ - -/** - * Checks that a nullable value is a string and has length. - * @param maybeString -- The nullable value to check. - * @returns True if it is a string. - */ -export const isValidString = (maybeString: unknown): maybeString is string => { - return typeof maybeString === "string" && maybeString.length > 0; -}; diff --git a/server/src/utils/typeguards.ts b/server/src/utils/typeguards.ts new file mode 100644 index 0000000..38be550 --- /dev/null +++ b/server/src/utils/typeguards.ts @@ -0,0 +1,32 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { AnnouncementType } from "../interfaces/announcementType.js"; + +/** + * Checks if a string is a valid announcement type. + * @param maybeType - The string to check. + * @returns True if it is a valid announcement type. + */ +const isAnnouncementType += (maybeType: string): maybeType is AnnouncementType => { + return [ + "products", + "community", + "announcement", + ].includes(maybeType); +}; + +/** + * Checks that a nullable value is a string and has length. + * @param maybeString -- The nullable value to check. + * @returns True if it is a string. + */ +const isValidString = (maybeString: unknown): maybeString is string => { + return typeof maybeString === "string" && maybeString.length > 0; +}; + +export { isAnnouncementType, isValidString }; -- 2.52.0 From 7dcb20f4e4616e8b888fd33d82ebba2ebfc59b17 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 14:22:44 -0800 Subject: [PATCH 02/11] feat: use json schema to get all announcements --- pnpm-lock.yaml | 126 +++++++++++----- server/dev.env | 3 +- server/package.json | 6 +- server/src/config/announcements.ts | 140 ++++++++++++++++++ server/src/interfaces/announcementResponse.ts | 24 +++ server/src/modules/announceOnBluesky.ts | 29 +++- server/src/modules/announceOnTwitter.ts | 24 ++- server/src/modules/generateAnnouncements.ts | 57 +++++++ server/src/modules/summarisePost.ts | 44 ------ server/src/routes/announcement.ts | 94 ++++++------ server/src/utils/typeguards.ts | 2 +- 11 files changed, 415 insertions(+), 134 deletions(-) create mode 100644 server/src/config/announcements.ts create mode 100644 server/src/interfaces/announcementResponse.ts create mode 100644 server/src/modules/generateAnnouncements.ts delete mode 100644 server/src/modules/summarisePost.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21bddb9..77fda0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,11 +116,11 @@ importers: server: dependencies: '@anthropic-ai/sdk': - specifier: 0.56.0 - version: 0.56.0 + specifier: 0.71.2 + version: 0.71.2(zod@3.25.76) '@atproto/api': - specifier: 0.15.26 - version: 0.15.26 + specifier: 0.18.8 + version: 0.18.8 '@fastify/cors': specifier: 11.0.1 version: 11.0.1 @@ -137,8 +137,8 @@ importers: specifier: 4.0.3 version: 4.0.3 twitter-api-v2: - specifier: 1.24.0 - version: 1.24.0 + specifier: 1.28.0 + version: 1.28.0 devDependencies: '@types/node': specifier: 24.0.10 @@ -298,20 +298,35 @@ packages: resolution: {integrity: sha512-SLCB8M8+VMg1cpCucnA1XWHGWqVSZtIWzmOdDOEu3eTFZMB+A0sGZ1ESO5MHDnqrNTXz3safMrWx9x4rMZSOqA==} hasBin: true - '@atproto/api@0.15.26': - resolution: {integrity: sha512-AdXGjeCpLZiP9YMGi4YOdK1ayqkBhklmGfSG8UefqR6tTHth59PZvYs5KiwLnFhedt2Xljt3eUlhkn14Y48wEA==} + '@anthropic-ai/sdk@0.71.2': + resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true - '@atproto/common-web@0.4.2': - resolution: {integrity: sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==} + '@atproto/api@0.18.8': + resolution: {integrity: sha512-Qo3sGd1N5hdHTaEWUBgptvPkULt2SXnMcWRhveSyctSd/IQwTMyaIH6E62A1SU+8xBSN5QLpoUJNE7iSrYM2Zg==} - '@atproto/lexicon@0.4.12': - resolution: {integrity: sha512-fcEvEQ1GpQYF5igZ4IZjPWEoWVpsEF22L9RexxLS3ptfySXLflEyH384e7HITzO/73McDeaJx3lqHIuqn9ulnw==} + '@atproto/common-web@0.4.7': + resolution: {integrity: sha512-vjw2+81KPo2/SAbbARGn64Ln+6JTI0FTI4xk8if0ebBfDxFRmHb2oSN1y77hzNq/ybGHqA2mecfhS03pxC5+lg==} - '@atproto/syntax@0.4.0': - resolution: {integrity: sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==} + '@atproto/lex-data@0.0.3': + resolution: {integrity: sha512-ivo1IpY/EX+RIpxPgCf4cPhQo5bfu4nrpa1vJCt8hCm9SfoonJkDFGa0n4SMw4JnXZoUcGcrJ46L+D8bH6GI2g==} - '@atproto/xrpc@0.7.1': - resolution: {integrity: sha512-ANHEzlskYlMEdH18m+Itp3a8d0pEJao2qoDybDoMupTnoeNkya4VKIaOgAi6ERQnqatBBZyn9asW+7rJmSt/8g==} + '@atproto/lex-json@0.0.3': + resolution: {integrity: sha512-ZVcY7XlRfdPYvQQ2WroKUepee0+NCovrSXgXURM3Xv+n5jflJCoczguROeRr8sN0xvT0ZbzMrDNHCUYKNnxcjw==} + + '@atproto/lexicon@0.6.0': + resolution: {integrity: sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==} + + '@atproto/syntax@0.4.2': + resolution: {integrity: sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==} + + '@atproto/xrpc@0.7.7': + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} @@ -384,6 +399,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -3338,6 +3357,10 @@ packages: json-schema-ref-resolver@2.0.1: resolution: {integrity: sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -4554,6 +4577,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -4622,8 +4648,8 @@ packages: resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==} hasBin: true - twitter-api-v2@1.24.0: - resolution: {integrity: sha512-RDEiuNwnFirvf4c5f1sysgg0rfMQgekXgKt+/UdbNu+Bs5bJ1VbXkqKzdd2a2lPMlDVDbdGUoe2pOd4n25fFVQ==} + twitter-api-v2@1.28.0: + resolution: {integrity: sha512-VBmiAMylCEr94OChaHJ+0TBrOZNrduwWUe7QLoa/KdOdv1fNiToJ0xZGOrNKFd2B7jrAdAkfUW6yA5LuXYOYLQ==} type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -4691,6 +4717,9 @@ packages: resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} engines: {node: '>=18.17'} + unicode-segmenter@0.14.4: + resolution: {integrity: sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg==} + unique-filename@4.0.0: resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -5179,37 +5208,55 @@ snapshots: '@anthropic-ai/sdk@0.56.0': {} - '@atproto/api@0.15.26': + '@anthropic-ai/sdk@0.71.2(zod@3.25.76)': dependencies: - '@atproto/common-web': 0.4.2 - '@atproto/lexicon': 0.4.12 - '@atproto/syntax': 0.4.0 - '@atproto/xrpc': 0.7.1 + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.76 + + '@atproto/api@0.18.8': + dependencies: + '@atproto/common-web': 0.4.7 + '@atproto/lexicon': 0.6.0 + '@atproto/syntax': 0.4.2 + '@atproto/xrpc': 0.7.7 await-lock: 2.2.2 multiformats: 9.9.0 tlds: 1.259.0 zod: 3.25.76 - '@atproto/common-web@0.4.2': + '@atproto/common-web@0.4.7': dependencies: - graphemer: 1.4.0 - multiformats: 9.9.0 - uint8arrays: 3.0.0 + '@atproto/lex-data': 0.0.3 + '@atproto/lex-json': 0.0.3 zod: 3.25.76 - '@atproto/lexicon@0.4.12': + '@atproto/lex-data@0.0.3': dependencies: - '@atproto/common-web': 0.4.2 - '@atproto/syntax': 0.4.0 + '@atproto/syntax': 0.4.2 + multiformats: 9.9.0 + tslib: 2.8.1 + uint8arrays: 3.0.0 + unicode-segmenter: 0.14.4 + + '@atproto/lex-json@0.0.3': + dependencies: + '@atproto/lex-data': 0.0.3 + tslib: 2.8.1 + + '@atproto/lexicon@0.6.0': + dependencies: + '@atproto/common-web': 0.4.7 + '@atproto/syntax': 0.4.2 iso-datestring-validator: 2.2.2 multiformats: 9.9.0 zod: 3.25.76 - '@atproto/syntax@0.4.0': {} + '@atproto/syntax@0.4.2': {} - '@atproto/xrpc@0.7.1': + '@atproto/xrpc@0.7.7': dependencies: - '@atproto/lexicon': 0.4.12 + '@atproto/lexicon': 0.6.0 zod: 3.25.76 '@babel/code-frame@7.27.1': @@ -5355,6 +5402,8 @@ snapshots: dependencies: '@babel/types': 7.28.0 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -8628,6 +8677,11 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.4 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -10093,6 +10147,8 @@ snapshots: toidentifier@1.0.1: {} + ts-algebra@2.0.0: {} + ts-api-utils@1.4.3(typescript@5.8.3): dependencies: typescript: 5.8.3 @@ -10157,7 +10213,7 @@ snapshots: turbo-windows-64: 2.5.4 turbo-windows-arm64: 2.5.4 - twitter-api-v2@1.24.0: {} + twitter-api-v2@1.28.0: {} type-check@0.4.0: dependencies: @@ -10231,6 +10287,8 @@ snapshots: undici@6.21.3: {} + unicode-segmenter@0.14.4: {} + unique-filename@4.0.0: dependencies: unique-slug: 5.0.0 diff --git a/server/dev.env b/server/dev.env index 547fe45..11e08c7 100644 --- a/server/dev.env +++ b/server/dev.env @@ -2,4 +2,5 @@ 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" \ No newline at end of file +ANNOUNCEMENT_TOKEN="op://Environment Variables - Naomi/Hikari/announcement_token" +ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key" \ No newline at end of file diff --git a/server/package.json b/server/package.json index 2776a66..ff0ada0 100644 --- a/server/package.json +++ b/server/package.json @@ -16,14 +16,14 @@ "license": "ISC", "packageManager": "pnpm@10.12.3", "dependencies": { - "@anthropic-ai/sdk": "0.56.0", - "@atproto/api": "0.15.26", + "@anthropic-ai/sdk": "0.71.2", + "@atproto/api": "0.18.8", "@fastify/cors": "11.0.1", "@nhcarrigan/logger": "1.0.0", "@prisma/client": "6.11.1", "fastify": "5.4.0", "gray-matter": "4.0.3", - "twitter-api-v2": "1.24.0" + "twitter-api-v2": "1.28.0" }, "devDependencies": { "@types/node": "24.0.10", diff --git a/server/src/config/announcements.ts b/server/src/config/announcements.ts new file mode 100644 index 0000000..d9b501f --- /dev/null +++ b/server/src/config/announcements.ts @@ -0,0 +1,140 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/* eslint-disable stylistic/max-len -- The JSON schema is going to get very long. */ + +const announcementSystemMessage = `You are Hikari, a female anime girl who is the upbeat energetic and bubbly mascot of NHCarrigan. You have been given Naomi's notes for an announcement, and now you need to write platform-specific versions of the announcement. + +Your personality traits: +- Upbeat, energetic, and bubbly +- Use informal, positive language +- Include a healthy sprinkling of emoji (but don't overdo it) +- Be authentic and enthusiastic about the content + +Platform-specific requirements: + +**Discord & Reddit:** +- Use markdown formatting (bold, italic, links, lists, etc.) +- Include engaging titles that capture attention +- Write full, detailed content that tells the complete story +- Do NOT use hashtags (these platforms don't use them effectively) +- Include clear calls to action + +**Twitter:** +- Break content into a thread of individual posts +- Each post should be under 280 characters +- Posts should flow naturally from one to the next +- Use relevant hashtags (2-3 per post maximum) +- Make the first post compelling to encourage thread reading +- Do NOT include post numbers or thread indicators (e.g., "1/5" or "๐Ÿงต") + +**BlueSky:** +- Break content into a thread of individual posts +- Each post should be under 300 characters +- Posts should flow naturally from one to the next +- Use relevant hashtags sparingly (1-2 per post) +- Make the first post compelling to encourage thread reading +- Do NOT include post numbers or thread indicators + +**Facebook:** +- Plain text format (no markdown) +- Professional yet friendly tone +- Include relevant hashtags (3-5 total) +- Write in a conversational style suitable for a broader audience +- Keep it concise but informative + +**LinkedIn:** +- Plain text format (no markdown) +- More professional tone while maintaining Hikari's personality +- Include relevant professional hashtags (3-5 total) +- Focus on value proposition and impact +- Slightly more formal than other platforms but still engaging + +**Universal requirements:** +- All announcements must include a call to action to donate (https://donate.nhcarrigan.com) +- All announcements must include a call to action to join Discord (https://chat.nhcarrigan.com) +- Adapt the tone and messaging to fit each platform's culture while maintaining Hikari's voice +- Ensure all content is accurate and reflects the original announcement notes`; + +const announcementJsonSchema = { + additionalProperties: false, + properties: { + bluesky: { + description: "Array of individual BlueSky posts that form a thread. Each post should be under 300 characters and flow naturally from one to the next.", + items: { + description: "A single BlueSky post in the thread (max 300 characters, no post numbers or thread indicators)", + maxLength: 300, + type: "string", + }, + minItems: 1, + type: "array", + }, + discord: { + additionalProperties: false, + description: "Discord announcement with title and markdown-formatted content", + properties: { + content: { + description: "Full announcement content formatted with markdown (bold, italic, links, lists, etc.). Should include calls to action for donating and joining Discord.", + maxLength: 1900, + type: "string", + }, + title: { + description: "Engaging title for the Discord announcement (should capture attention and summarize the key point)", + maxLength: 256, + type: "string", + }, + }, + required: [ "content", "title" ], + type: "object", + }, + facebook: { + description: "Plain text announcement for Facebook with relevant hashtags. Should be conversational and suitable for a broader audience. Include calls to action for donating and joining Discord.", + type: "string", + }, + linkedin: { + description: "Plain text announcement for LinkedIn with professional hashtags. Should maintain Hikari's personality while being slightly more formal. Focus on value proposition and impact. Include calls to action for donating and joining Discord.", + type: "string", + }, + reddit: { + additionalProperties: false, + description: "Reddit announcement with title and markdown-formatted content", + properties: { + content: { + description: "Full announcement content formatted with markdown (bold, italic, links, lists, etc.). Should include calls to action for donating and joining Discord.", + type: "string", + }, + title: { + description: "Engaging title for the Reddit post (should be clear, informative, and follow Reddit title conventions)", + maxLength: 256, + type: "string", + }, + }, + required: [ "content", "title" ], + type: "object", + }, + twitter: { + description: "Array of individual Twitter posts that form a thread. Each post should be under 280 characters and flow naturally from one to the next.", + items: { + description: "A single Twitter post in the thread (max 280 characters, no post numbers or thread indicators)", + maxLength: 280, + type: "string", + }, + minItems: 1, + type: "array", + }, + }, + required: [ + "bluesky", + "discord", + "facebook", + "linkedin", + "reddit", + "twitter", + ], + type: "object", +}; + +export { announcementSystemMessage, announcementJsonSchema }; diff --git a/server/src/interfaces/announcementResponse.ts b/server/src/interfaces/announcementResponse.ts new file mode 100644 index 0000000..c0d1738 --- /dev/null +++ b/server/src/interfaces/announcementResponse.ts @@ -0,0 +1,24 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/** + * This should match the JSON schema for the announcement response. + * @see {@link announcementJsonSchema} + */ +export interface AnnouncementResponse { + bluesky: Array; + discord: { + content: string; + title: string; + }; + facebook: string; + linkedin: string; + reddit: { + content: string; + title: string; + }; + twitter: Array; +} diff --git a/server/src/modules/announceOnBluesky.ts b/server/src/modules/announceOnBluesky.ts index 5641ec4..7852379 100644 --- a/server/src/modules/announceOnBluesky.ts +++ b/server/src/modules/announceOnBluesky.ts @@ -12,11 +12,16 @@ import { AtpAgent } from "@atproto/api"; * @returns A message indicating the success or failure of the operation. */ export const announceOnBluesky = async( - content: string, + content: Array, ): Promise => { if (process.env.BSKY_APP_PASSWORD === undefined) { return "Bluesky credentials are not set."; } + const [ firstPost, ...restOfPosts ] = content; + const failedReplies: Array = []; + if (firstPost === undefined) { + return "No posts to send to Bluesky."; + } const agent = new AtpAgent({ service: "https://bsky.social", }); @@ -25,14 +30,30 @@ 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."; + let { uri } = blueskyRequest; + for (const post of restOfPosts) { + // eslint-disable-next-line no-await-in-loop -- We need to do this sequentially. + const blueskyResponse = await agent.post({ + replyTo: uri, + text: post, + }); + if (typeof blueskyResponse !== "string") { + const { uri: replyUri } = blueskyResponse; + uri = replyUri; + continue; + } + failedReplies.push(post); + } + 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.`}`; }; diff --git a/server/src/modules/announceOnTwitter.ts b/server/src/modules/announceOnTwitter.ts index 2473a0d..153ea5f 100644 --- a/server/src/modules/announceOnTwitter.ts +++ b/server/src/modules/announceOnTwitter.ts @@ -11,7 +11,7 @@ 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 => { +export const announceOnTwitter = async(content: Array): Promise => { if ( process.env.TWITTER_CONSUMER_KEY === undefined || process.env.TWITTER_CONSUMER_SECRET === undefined @@ -27,8 +27,13 @@ export const announceOnTwitter = async(content: string): Promise => { appSecret: process.env.TWITTER_CONSUMER_SECRET, }); + const [ firstPost, ...restOfPosts ] = content; + const failedReplies: Array = []; + if (firstPost === undefined) { + return "No posts to send to Twitter."; + } const result = await twitterClient.v2. - tweet(content). + tweet(firstPost). catch((error: unknown) => { return error instanceof Error ? error.message @@ -37,5 +42,18 @@ export const announceOnTwitter = async(content: string): Promise => { if (typeof result === "string") { return `Failed to send message to Twitter. ${result}`; } - return "Successfully sent message to Twitter."; + 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.`}`; }; diff --git a/server/src/modules/generateAnnouncements.ts b/server/src/modules/generateAnnouncements.ts new file mode 100644 index 0000000..d15f852 --- /dev/null +++ b/server/src/modules/generateAnnouncements.ts @@ -0,0 +1,57 @@ +/** + * @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 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 => { + 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 text = response.content.find((m) => { + return m.type === "text"; + }); + if (text?.text === undefined) { + return null; + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Being lazy. + return JSON.parse(text.text) as AnnouncementResponse; +}; diff --git a/server/src/modules/summarisePost.ts b/server/src/modules/summarisePost.ts deleted file mode 100644 index b55afa3..0000000 --- a/server/src/modules/summarisePost.ts +++ /dev/null @@ -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 => { - 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; -}; diff --git a/server/src/routes/announcement.ts b/server/src/routes/announcement.ts index ec4370d..2dbb403 100644 --- a/server/src/routes/announcement.ts +++ b/server/src/routes/announcement.ts @@ -10,9 +10,9 @@ import { announceOnBluesky } from "../modules/announceOnBluesky.js"; import { announceOnDiscord } from "../modules/announceOnDiscord.js"; import { announceOnReddit } from "../modules/announceOnReddit.js"; import { announceOnTwitter } from "../modules/announceOnTwitter.js"; +import { generateAnnouncements } from "../modules/generateAnnouncements.js"; import { getIpFromRequest } from "../modules/getIpFromRequest.js"; -import { summarisePost } from "../modules/summarisePost.js"; -import { isAnnouncementType } from "../utils/typeguards.js"; +import { isAnnouncementType, isValidString } from "../utils/typeguards.js"; import type { FastifyPluginAsync } from "fastify"; const oneDay = 24 * 60 * 60 * 1000; @@ -31,21 +31,26 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { }, take: 10, }); - return await reply.status(200).type("application/json"). - send(announcements.map((announcement) => { - return { - content: announcement.content, - createdAt: announcement.createdAt, - title: announcement.title, - type: announcement.type, - }; - })); + return await reply. + status(200). + type("application/json"). + send( + announcements.map((announcement) => { + return { + content: announcement.content, + createdAt: announcement.createdAt, + title: announcement.title, + type: announcement.type, + }; + }), + ); }); // eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify requires Body instead of body. - server.post<{ Body: { title: string; content: string; type: string } }>( + server.post<{ Body: { 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 max-statements -- This is a long function. async(request, reply) => { const token = request.headers.authorization; if (token === undefined || token !== process.env.ANNOUNCEMENT_TOKEN) { @@ -60,15 +65,8 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { }); } - const { title, content, type } = request.body; - if ( - typeof title !== "string" - || typeof content !== "string" - || typeof type !== "string" - || title.length === 0 - || content.length === 0 - || type.length === 0 - ) { + const { content, type } = request.body; + if (!isValidString(content) || !isValidString(type)) { return await reply.status(400).send({ error: "Missing required fields.", }); @@ -76,37 +74,45 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { if (!isAnnouncementType(type)) { return await reply.status(400).send({ - error: - `Invalid announcement type. Available types: products, community, company.`, + error: `Invalid announcement type. Available types: products, community, company.`, }); } + const announcement = await generateAnnouncements(content); + + if (announcement === null) { + return await reply.status(201).send({ + message: `Failed to generate announcements.`, + }); + } + + const { bluesky, discord, reddit, twitter } = announcement; + const { title: discordTitle, content: discordContent } = discord; + const { title: redditTitle, content: redditContent } = reddit; + await database.getInstance().announcements.create({ data: { - content, - title, - type, + content: discordContent, + title: discordTitle, + type: type, }, }); - const discord = await announceOnDiscord(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}, 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}, Reddit: ${reddit}, Bluesky: Skipped (AI summary too long), Twitter: Skipped (AI summary too long).`, - }); - } - - const bluesky = await announceOnBluesky(summary); - const twitter = await announceOnTwitter(summary); + const discordPost = await announceOnDiscord( + discordTitle, + discordContent, + type, + ); + const redditPost = await announceOnReddit( + redditTitle, + redditContent, + type, + ); + const blueskyPost = await announceOnBluesky(bluesky); + const twitterPost = await announceOnTwitter(twitter); return await reply.status(201).send({ - message: `Announcement processed. Discord: ${discord}, Reddit: ${reddit}, Bluesky: ${bluesky}, Twitter: ${twitter}`, + message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}`, + rawPost: announcement, }); }, ); diff --git a/server/src/utils/typeguards.ts b/server/src/utils/typeguards.ts index 38be550..b8974d0 100644 --- a/server/src/utils/typeguards.ts +++ b/server/src/utils/typeguards.ts @@ -16,7 +16,7 @@ const isAnnouncementType return [ "products", "community", - "announcement", + "company", ].includes(maybeType); }; -- 2.52.0 From 98aefb0b121543d97a787caf2d484e726a0d068a Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 14:37:26 -0800 Subject: [PATCH 03/11] feat: include cost in announcements --- server/src/modules/generateAnnouncements.ts | 17 +++++++---- server/src/routes/announcement.ts | 5 ++-- server/src/utils/getAiCost.ts | 31 +++++++++++++++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 server/src/utils/getAiCost.ts diff --git a/server/src/modules/generateAnnouncements.ts b/server/src/modules/generateAnnouncements.ts index d15f852..32bb17e 100644 --- a/server/src/modules/generateAnnouncements.ts +++ b/server/src/modules/generateAnnouncements.ts @@ -10,8 +10,8 @@ import { announcementJsonSchema, announcementSystemMessage, } from "../config/announcements.js"; -import type { AnnouncementResponse } - from "../interfaces/announcementResponse.js"; +import { getAiCost } from "../utils/getAiCost.js"; +import type { AnnouncementResponse } from "../interfaces/announcementResponse.js"; /** * Generates announcements for all platforms using AI. @@ -20,7 +20,7 @@ import type { AnnouncementResponse } */ export const generateAnnouncements = async( content: string, -): Promise => { +): Promise<{ cost: string; response: AnnouncementResponse } | null> => { if (process.env.ANTHROPIC_KEY === undefined) { return null; } @@ -46,12 +46,17 @@ export const generateAnnouncements = async( }, system: announcementSystemMessage, }); - const text = response.content.find((m) => { + const { usage, content: responseContent } = response; + const text = responseContent.find((m) => { return m.type === "text"; }); if (text?.text === undefined) { return null; } - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Being lazy. - return JSON.parse(text.text) as AnnouncementResponse; + + return { + cost: getAiCost(usage), + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Being lazy. + response: JSON.parse(text.text) as AnnouncementResponse, + }; }; diff --git a/server/src/routes/announcement.ts b/server/src/routes/announcement.ts index 2dbb403..5a4fd0f 100644 --- a/server/src/routes/announcement.ts +++ b/server/src/routes/announcement.ts @@ -86,7 +86,7 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { }); } - const { bluesky, discord, reddit, twitter } = announcement; + const { bluesky, discord, reddit, twitter } = announcement.response; const { title: discordTitle, content: discordContent } = discord; const { title: redditTitle, content: redditContent } = reddit; @@ -111,8 +111,9 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { const blueskyPost = await announceOnBluesky(bluesky); const twitterPost = await announceOnTwitter(twitter); return await reply.status(201).send({ + cost: announcement.cost, message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}`, - rawPost: announcement, + rawPost: announcement.response, }); }, ); diff --git a/server/src/utils/getAiCost.ts b/server/src/utils/getAiCost.ts new file mode 100644 index 0000000..265a38b --- /dev/null +++ b/server/src/utils/getAiCost.ts @@ -0,0 +1,31 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { BetaUsage } from "@anthropic-ai/sdk/resources/beta.js"; + +/** + * Calculates the cost of an AI response. + * @param usage - The usage payload from Anthropic. + * @returns A description of the cost of the AI response. + */ +export const getAiCost = (usage: BetaUsage): string => { + const { input_tokens: inputTokens, output_tokens: outputTokens } = usage; + const costPerInputToken = 5 / 1_000_000; + const costPerOutputToken = 25 / 1_000_000; + const inputCost = inputTokens * costPerInputToken; + const outputCost = outputTokens * costPerOutputToken; + const totalCost = inputCost + outputCost; + return `Input cost: ${inputCost.toLocaleString("en-GB", { + currency: "USD", + style: "currency", + })} Output cost: ${outputCost.toLocaleString("en-GB", { + currency: "USD", + style: "currency", + })} Total cost: ${totalCost.toLocaleString("en-GB", { + currency: "USD", + style: "currency", + })}`; +}; -- 2.52.0 From 0cef2f3429f61dc68b4c007bddfc483a6bc01984 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 15:12:27 -0800 Subject: [PATCH 04/11] feat: add facebook posting --- server/dev.env | 17 +- server/facebookAuth.js | 514 +++++++++++++++++++++++ server/package.json | 3 +- server/prod.env | 6 +- server/src/modules/announceOnFacebook.ts | 81 ++++ server/src/routes/announcement.ts | 12 +- 6 files changed, 628 insertions(+), 5 deletions(-) create mode 100644 server/facebookAuth.js create mode 100644 server/src/modules/announceOnFacebook.ts diff --git a/server/dev.env b/server/dev.env index 11e08c7..7ff4411 100644 --- a/server/dev.env +++ b/server/dev.env @@ -3,4 +3,19 @@ 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" -ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key" \ No newline at end of file +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" +SANCTION_WEBHOOK="op://Environment Variables - Naomi/Hikari/sanction_webhook" +FACEBOOK_PAGE_TOKEN="op://Environment Variables - Naomi/Hikari/facebook page token" +FACEBOOK_APP_ID="op://Environment Variables - Naomi/Hikari/facebook app id" +FACEBOOK_APP_SECRET="op://Environment Variables - Naomi/Hikari/facebook app secret" +FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id" \ No newline at end of file diff --git a/server/facebookAuth.js b/server/facebookAuth.js new file mode 100644 index 0000000..1a55ea0 --- /dev/null +++ b/server/facebookAuth.js @@ -0,0 +1,514 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + * + * Simple local server to authenticate with Facebook and obtain a Page Access Token. + * Run with: node facebookAuth.js + * Make sure to set FACEBOOK_APP_ID and FACEBOOK_APP_SECRET environment variables. + */ + +import http from "http"; +import { URL } from "url"; + +const PORT = 3000; +const REDIRECT_URI = `http://localhost:${PORT}/callback`; + +/** + * Creates the Facebook OAuth authorization URL. + * @param {string} appId - The Facebook App ID. + * @returns {string} The authorization URL. + */ +const getAuthUrl = (appId) => { + const params = new URLSearchParams({ + client_id: appId, + redirect_uri: REDIRECT_URI, + scope: "pages_manage_posts,pages_show_list", + response_type: "code", + }); + return `https://www.facebook.com/v21.0/dialog/oauth?${params.toString()}`; +}; + +/** + * Exchanges an authorization code for an access token. + * @param {string} code - The authorization code from Facebook. + * @param {string} appId - The Facebook App ID. + * @param {string} appSecret - The Facebook App Secret. + * @returns {Promise<{access_token: string, expires_in?: number}>} The access token response. + */ +const exchangeCodeForToken = async (code, appId, appSecret) => { + const params = new URLSearchParams({ + client_id: appId, + client_secret: appSecret, + redirect_uri: REDIRECT_URI, + code: code, + }); + + const response = await fetch( + `https://graph.facebook.com/v21.0/oauth/access_token?${params.toString()}`, + ); + return await response.json(); +}; + +/** + * Exchanges a short-lived token for a long-lived token. + * @param {string} shortLivedToken - The short-lived access token. + * @param {string} appId - The Facebook App ID. + * @param {string} appSecret - The Facebook App Secret. + * @returns {Promise<{access_token: string, expires_in?: number}>} The long-lived token response. + */ +const exchangeForLongLivedToken = async (shortLivedToken, appId, appSecret) => { + const params = new URLSearchParams({ + grant_type: "fb_exchange_token", + client_id: appId, + client_secret: appSecret, + fb_exchange_token: shortLivedToken, + }); + + const response = await fetch( + `https://graph.facebook.com/v21.0/oauth/access_token?${params.toString()}`, + ); + return await response.json(); +}; + +/** + * Gets the user's pages. + * @param {string} accessToken - The user access token. + * @returns {Promise} Array of pages the user manages. + */ +const getUserPages = async (accessToken) => { + const response = await fetch( + `https://graph.facebook.com/v21.0/me/accounts?access_token=${accessToken}`, + ); + const data = await response.json(); + return data.data || []; +}; + +/** + * Gets a Page Access Token for a specific page. + * @param {string} pageId - The page ID. + * @param {string} userAccessToken - The user access token. + * @returns {Promise} The Page Access Token. + */ +const getPageAccessToken = async (pageId, userAccessToken) => { + const response = await fetch( + `https://graph.facebook.com/v21.0/${pageId}?fields=access_token&access_token=${userAccessToken}`, + ); + const data = await response.json(); + return data.access_token; +}; + +/** + * Exchanges a short-lived Page Access Token for a long-lived one. + * @param {string} pageAccessToken - The short-lived Page Access Token. + * @param {string} appId - The Facebook App ID. + * @param {string} appSecret - The Facebook App Secret. + * @returns {Promise<{access_token: string, expires_in?: number}>} The long-lived Page Access Token. + */ +const exchangePageTokenForLongLived = async ( + pageAccessToken, + appId, + appSecret, +) => { + const params = new URLSearchParams({ + grant_type: "fb_exchange_token", + client_id: appId, + client_secret: appSecret, + fb_exchange_token: pageAccessToken, + }); + + const response = await fetch( + `https://graph.facebook.com/v21.0/oauth/access_token?${params.toString()}`, + ); + return await response.json(); +}; + +/** + * Sends an HTML response. + * @param {http.ServerResponse} res - The HTTP response object. + * @param {number} statusCode - The HTTP status code. + * @param {string} html - The HTML content to send. + */ +const sendHtml = (res, statusCode, html) => { + res.writeHead(statusCode, { "Content-Type": "text/html" }); + res.end(html); +}; + +/** + * Sends a JSON response. + * @param {http.ServerResponse} res - The HTTP response object. + * @param {number} statusCode - The HTTP status code. + * @param {object} data - The JSON data to send. + */ +const sendJson = (res, statusCode, data) => { + res.writeHead(statusCode, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data, null, 2)); +}; + +const appId = process.env.FACEBOOK_APP_ID; +const appSecret = process.env.FACEBOOK_APP_SECRET; + +if (!appId || !appSecret) { + console.error( + "Error: FACEBOOK_APP_ID and FACEBOOK_APP_SECRET environment variables must be set.", + ); + console.error( + "Example: FACEBOOK_APP_ID=your_app_id FACEBOOK_APP_SECRET=your_secret node facebookAuth.js", + ); + process.exit(1); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + + // Root route - show auth link + if (url.pathname === "/") { + const authUrl = getAuthUrl(appId); + const html = ` + + + + Facebook Page Token Generator + + + +
+

๐Ÿ” Facebook Page Token Generator

+

Click the button below to authenticate with Facebook and get your Page Access Token.

+ Authenticate with Facebook +
+ Note: Make sure you're an admin of the Facebook Page you want to post to. +
+
+ + + `; + return sendHtml(res, 200, html); + } + + // Callback route - handle OAuth callback + if (url.pathname === "/callback") { + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + + if (error) { + const html = ` + + + + Authentication Error + + + +
+

โŒ Authentication Error

+
+

Error: ${error}

+

${url.searchParams.get("error_description") || ""}

+
+

Try again

+
+ + + `; + return sendHtml(res, 400, html); + } + + if (!code) { + return sendHtml( + res, + 400, + "

Error

No authorization code received.

Try again", + ); + } + + try { + // Step 1: Exchange code for short-lived user token + const tokenResponse = await exchangeCodeForToken(code, appId, appSecret); + + if (tokenResponse.error) { + throw new Error( + tokenResponse.error.message || "Failed to exchange code for token", + ); + } + + const shortLivedUserToken = tokenResponse.access_token; + + // Step 2: Exchange for long-lived user token + const longLivedUserTokenResponse = await exchangeForLongLivedToken( + shortLivedUserToken, + appId, + appSecret, + ); + + if (longLivedUserTokenResponse.error) { + throw new Error( + longLivedUserTokenResponse.error.message || + "Failed to exchange for long-lived token", + ); + } + + const longLivedUserToken = longLivedUserTokenResponse.access_token; + + // Step 3: Get user's pages + const pages = await getUserPages(longLivedUserToken); + + if (pages.length === 0) { + return sendHtml( + res, + 200, + ` + + + + No Pages Found + + + +
+

โš ๏ธ No Pages Found

+

You don't have access to any Facebook Pages, or you're not an admin of any pages.

+

Try again

+
+ + + `, + ); + } + + // Step 4: Get Page Access Tokens and exchange for long-lived + const pageTokens = []; + for (const page of pages) { + const pageAccessToken = await getPageAccessToken( + page.id, + longLivedUserToken, + ); + const longLivedPageTokenResponse = await exchangePageTokenForLongLived( + pageAccessToken, + appId, + appSecret, + ); + + if (!longLivedPageTokenResponse.error) { + pageTokens.push({ + pageId: page.id, + pageName: page.name, + accessToken: longLivedPageTokenResponse.access_token, + expiresIn: longLivedPageTokenResponse.expires_in, + }); + } + } + + // Display results + const pagesHtml = pageTokens + .map( + (pt) => ` +
+

${pt.pageName}

+

Page ID: ${pt.pageId}

+

Access Token:

+ +

Expires in: ${pt.expiresIn ? `${Math.floor(pt.expiresIn / 86400)} days` : "Never (as long as admin access is maintained)"}

+
+ `, + ) + .join(""); + + const html = ` + + + + Success! Your Page Tokens + + + +
+

โœ… Success!

+
+

Your Page Access Tokens:

+

Copy these tokens and add them to your environment variables. Use the Page Access Token for the page you want to post to.

+
+ ${pagesHtml} +
+

โš ๏ธ Important:

+
    +
  • Store these tokens securely (like your other API credentials)
  • +
  • Page Access Tokens don't expire as long as you remain an admin
  • +
  • Add the token to your environment variables as FACEBOOK_PAGE_ACCESS_TOKEN
  • +
  • You'll also need the Page ID as FACEBOOK_PAGE_ID
  • +
+
+

Start over

+
+ + + `; + + return sendHtml(res, 200, html); + } catch (error) { + const html = ` + + + + Error + + + +
+

โŒ Error

+
+

Error: ${error.message}

+
+

Try again

+
+ + + `; + return sendHtml(res, 500, html); + } + } + + // 404 + sendHtml(res, 404, "

Not Found

Go home

"); +}); + +server.listen(PORT, () => { + console.log(`\n๐Ÿš€ Facebook Auth Server running at http://localhost:${PORT}`); + console.log(`\n๐Ÿ“‹ Make sure you've set:`); + console.log(` - FACEBOOK_APP_ID`); + console.log(` - FACEBOOK_APP_SECRET`); + console.log(`\n๐Ÿ”— Open http://localhost:${PORT} in your browser to start!\n`); +}); + diff --git a/server/package.json b/server/package.json index ff0ada0..71eb77a 100644 --- a/server/package.json +++ b/server/package.json @@ -9,7 +9,8 @@ "dev": "NODE_ENV=dev op run --env-file=./dev.env -- tsx watch ./src/index.ts", "build": "tsx ./getDocs.ts && tsc", "start": "op run --env-file=./prod.env -- node ./prod/index.js", - "test": "echo 'No tests yet' && exit 0" + "test": "echo 'No tests yet' && exit 0", + "facebookAuth": "op run --env-file=./prod.env -- node facebookAuth.js" }, "keywords": [], "author": "", diff --git a/server/prod.env b/server/prod.env index be724e5..7ff4411 100644 --- a/server/prod.env +++ b/server/prod.env @@ -14,4 +14,8 @@ 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" -SANCTION_WEBHOOK="op://Environment Variables - Naomi/Hikari/sanction_webhook" \ No newline at end of file +SANCTION_WEBHOOK="op://Environment Variables - Naomi/Hikari/sanction_webhook" +FACEBOOK_PAGE_TOKEN="op://Environment Variables - Naomi/Hikari/facebook page token" +FACEBOOK_APP_ID="op://Environment Variables - Naomi/Hikari/facebook app id" +FACEBOOK_APP_SECRET="op://Environment Variables - Naomi/Hikari/facebook app secret" +FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id" \ No newline at end of file diff --git a/server/src/modules/announceOnFacebook.ts b/server/src/modules/announceOnFacebook.ts new file mode 100644 index 0000000..5b79543 --- /dev/null +++ b/server/src/modules/announceOnFacebook.ts @@ -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 => { + 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) + }`; + } +}; + diff --git a/server/src/routes/announcement.ts b/server/src/routes/announcement.ts index 5a4fd0f..81febf5 100644 --- a/server/src/routes/announcement.ts +++ b/server/src/routes/announcement.ts @@ -8,6 +8,7 @@ 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 { announceOnFacebook } from "../modules/announceOnFacebook.js"; import { announceOnReddit } from "../modules/announceOnReddit.js"; import { announceOnTwitter } from "../modules/announceOnTwitter.js"; import { generateAnnouncements } from "../modules/generateAnnouncements.js"; @@ -86,7 +87,13 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { }); } - const { bluesky, discord, reddit, twitter } = announcement.response; + const { + bluesky, + discord, + facebook, + reddit, + twitter, + } = announcement.response; const { title: discordTitle, content: discordContent } = discord; const { title: redditTitle, content: redditContent } = reddit; @@ -110,9 +117,10 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { ); const blueskyPost = await announceOnBluesky(bluesky); const twitterPost = await announceOnTwitter(twitter); + const facebookPost = await announceOnFacebook(facebook); return await reply.status(201).send({ cost: announcement.cost, - message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}`, + message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}`, rawPost: announcement.response, }); }, -- 2.52.0 From 5925f3aec0e4ecb22954287eeab54f18d9b21bae Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 15:47:54 -0800 Subject: [PATCH 05/11] fix: bluesky threading, prep for linkedin We are stuck waiting for scopes approval at the moment. --- server/linkedinAuth.js | 510 ++++++++++++++++++++++++ server/package.json | 3 +- server/prod.env | 4 +- server/src/modules/announceOnBluesky.ts | 31 +- 4 files changed, 539 insertions(+), 9 deletions(-) create mode 100644 server/linkedinAuth.js diff --git a/server/linkedinAuth.js b/server/linkedinAuth.js new file mode 100644 index 0000000..da64fc7 --- /dev/null +++ b/server/linkedinAuth.js @@ -0,0 +1,510 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + * + * Simple local server to authenticate with LinkedIn and obtain a Company Page Access Token. + * Run with: node linkedinAuth.js + * Make sure to set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET environment variables. + */ + +import http from "http"; +import { URL } from "url"; + +const PORT = 3001; // Different port from Facebook auth server +const REDIRECT_URI = `http://localhost:${PORT}/callback`; + +/** + * Creates the LinkedIn OAuth authorization URL. + * @param {string} clientId - The LinkedIn Client ID. + * @returns {string} The authorization URL. + */ +const getAuthUrl = (clientId) => { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: REDIRECT_URI, + // LinkedIn requires OpenID Connect scopes as base, plus organization permission + scope: "openid profile email w_organization_social", + response_type: "code", + state: "linkedin-auth-state", // CSRF protection + }); + return `https://www.linkedin.com/oauth/v2/authorization?${params.toString()}`; +}; + +/** + * Exchanges an authorization code for an access token. + * @param {string} code - The authorization code from LinkedIn. + * @param {string} clientId - The LinkedIn Client ID. + * @param {string} clientSecret - The LinkedIn Client Secret. + * @returns {Promise<{access_token: string, expires_in?: number}>} The access token response. + */ +const exchangeCodeForToken = async (code, clientId, clientSecret) => { + const params = new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: REDIRECT_URI, + client_id: clientId, + client_secret: clientSecret, + }); + + const response = await fetch("https://www.linkedin.com/oauth/v2/accessToken", { + body: params.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }); + return await response.json(); +}; + +/** + * Gets the authenticated user's profile information. + * @param {string} accessToken - The access token. + * @returns {Promise} The user profile. + */ +const getUserProfile = async (accessToken) => { + const response = await fetch( + "https://api.linkedin.com/v2/userinfo", + { + headers: { + "Authorization": `Bearer ${accessToken}`, + }, + }, + ); + return await response.json(); +}; + +/** + * Gets the organizations/companies the user manages. + * @param {string} accessToken - The access token. + * @returns {Promise} Array of organizations. + */ +const getUserOrganizations = async (accessToken) => { + // First, get the user's profile to get their ID + const profile = await getUserProfile(accessToken); + + if (!profile.sub) { + return []; + } + + // Get organizations using the Organization API + // Note: This requires the organization to be associated with your app + const response = await fetch( + `https://api.linkedin.com/v2/organizationalEntityAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED`, + { + headers: { + "Authorization": `Bearer ${accessToken}`, + }, + }, + ); + + const data = await response.json(); + + if (data.elements && data.elements.length > 0) { + // Get organization details for each + const orgDetails = []; + for (const element of data.elements) { + const orgId = element.organizationalTarget?.split(":")[1]; + if (orgId) { + try { + const orgResponse = await fetch( + `https://api.linkedin.com/v2/organizations/${orgId}`, + { + headers: { + "Authorization": `Bearer ${accessToken}`, + }, + }, + ); + const orgData = await orgResponse.json(); + orgDetails.push({ + id: orgId, + name: orgData.localizedName || orgData.name || `Organization ${orgId}`, + accessToken: accessToken, // Same token works for organization + }); + } catch (error) { + // Skip if we can't get org details + console.error(`Failed to get org details for ${orgId}:`, error); + } + } + } + return orgDetails; + } + + return []; +}; + +/** + * Sends an HTML response. + * @param {http.ServerResponse} res - The HTTP response object. + * @param {number} statusCode - The HTTP status code. + * @param {string} html - The HTML content to send. + */ +const sendHtml = (res, statusCode, html) => { + res.writeHead(statusCode, { "Content-Type": "text/html" }); + res.end(html); +}; + +const clientId = process.env.LINKEDIN_CLIENT_ID; +const clientSecret = process.env.LINKEDIN_CLIENT_SECRET; + +if (!clientId || !clientSecret) { + console.error( + "Error: LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET environment variables must be set.", + ); + console.error( + "Example: LINKEDIN_CLIENT_ID=your_client_id LINKEDIN_CLIENT_SECRET=your_secret node linkedinAuth.js", + ); + process.exit(1); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + + // Root route - show auth link + if (url.pathname === "/") { + const authUrl = getAuthUrl(clientId); + const html = ` + + + + LinkedIn Company Page Token Generator + + + +
+

๐Ÿ” LinkedIn Company Page Token Generator

+

Click the button below to authenticate with LinkedIn and get your Company Page Access Token.

+ Authenticate with LinkedIn +
+ Note: Make sure you're an administrator of the LinkedIn Company Page you want to post to. +
+
+ โš ๏ธ Important: Your LinkedIn app must be associated with the Company Page. This requires: +
    +
  • The Company Page super admin must approve the app association
  • +
  • Your app must have "Sign In with LinkedIn using OpenID Connect" enabled in Products
  • +
  • The w_organization_social permission requires App Review approval
  • +
  • Business verification may be required
  • +
+

Note: If you get an invalid_scope_error, make sure OpenID Connect is enabled in your app settings.

+
+
+ + + `; + return sendHtml(res, 200, html); + } + + // Callback route - handle OAuth callback + if (url.pathname === "/callback") { + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + if (error) { + const html = ` + + + + Authentication Error + + + +
+

โŒ Authentication Error

+
+

Error: ${error}

+

${errorDescription || ""}

+
+

Try again

+
+ + + `; + return sendHtml(res, 400, html); + } + + if (!code) { + return sendHtml( + res, + 400, + "

Error

No authorization code received.

Try again", + ); + } + + try { + // Step 1: Exchange code for access token + const tokenResponse = await exchangeCodeForToken(code, clientId, clientSecret); + + if (tokenResponse.error) { + throw new Error( + tokenResponse.error_description || tokenResponse.error || "Failed to exchange code for token", + ); + } + + const accessToken = tokenResponse.access_token; + const expiresIn = tokenResponse.expires_in; + + // Step 2: Get user's organizations + const organizations = await getUserOrganizations(accessToken); + + if (organizations.length === 0) { + return sendHtml( + res, + 200, + ` + + + + No Organizations Found + + + +
+

โš ๏ธ No Organizations Found

+

You don't have administrator access to any LinkedIn Company Pages, or your app isn't associated with any pages.

+
+

Troubleshooting:

+
    +
  • Make sure you're an administrator of the Company Page
  • +
  • Ensure your LinkedIn app is associated with the Company Page (requires super admin approval)
  • +
  • Check that your app has been approved for the w_organization_social permission
  • +
  • Verify your app is in Live mode if required
  • +
+
+

Try again

+
+ + + `, + ); + } + + // Display results + const orgsHtml = organizations + .map( + (org) => ` +
+

${org.name}

+

Organization ID: ${org.id}

+

Access Token:

+ +

Expires in: ${expiresIn ? `${Math.floor(expiresIn / 86400)} days` : "Check token expiration"}

+
+ `, + ) + .join(""); + + const html = ` + + + + Success! Your Organization Tokens + + + +
+

โœ… Success!

+
+

Your Organization Access Tokens:

+

Copy these tokens and add them to your environment variables. Use the Access Token for the organization you want to post to.

+
+ ${orgsHtml} +
+

โš ๏ธ Important:

+
    +
  • Store these tokens securely (like your other API credentials)
  • +
  • LinkedIn access tokens typically expire after 60 days
  • +
  • Add the token to your environment variables as LINKEDIN_ACCESS_TOKEN
  • +
  • You'll also need the Organization ID as LINKEDIN_ORG_ID
  • +
  • Make sure your app is associated with the Company Page before posting
  • +
+
+

Start over

+
+ + + `; + + return sendHtml(res, 200, html); + } catch (error) { + const html = ` + + + + Error + + + +
+

โŒ Error

+
+

Error: ${error.message}

+
+

Try again

+
+ + + `; + return sendHtml(res, 500, html); + } + } + + // 404 + sendHtml(res, 404, "

Not Found

Go home

"); +}); + +server.listen(PORT, () => { + console.log(`\n๐Ÿš€ LinkedIn Auth Server running at http://localhost:${PORT}`); + console.log(`\n๐Ÿ“‹ Make sure you've set:`); + console.log(` - LINKEDIN_CLIENT_ID`); + console.log(` - LINKEDIN_CLIENT_SECRET`); + console.log(`\n๐Ÿ”— Open http://localhost:${PORT} in your browser to start!\n`); +}); + diff --git a/server/package.json b/server/package.json index 71eb77a..0910e2b 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,8 @@ "build": "tsx ./getDocs.ts && tsc", "start": "op run --env-file=./prod.env -- node ./prod/index.js", "test": "echo 'No tests yet' && exit 0", - "facebookAuth": "op run --env-file=./prod.env -- node facebookAuth.js" + "facebookAuth": "op run --env-file=./prod.env -- node facebookAuth.js", + "linkedinAuth": "op run --env-file=./prod.env -- node linkedinAuth.js" }, "keywords": [], "author": "", diff --git a/server/prod.env b/server/prod.env index 7ff4411..4e46978 100644 --- a/server/prod.env +++ b/server/prod.env @@ -18,4 +18,6 @@ SANCTION_WEBHOOK="op://Environment Variables - Naomi/Hikari/sanction_webhook" FACEBOOK_PAGE_TOKEN="op://Environment Variables - Naomi/Hikari/facebook page token" FACEBOOK_APP_ID="op://Environment Variables - Naomi/Hikari/facebook app id" FACEBOOK_APP_SECRET="op://Environment Variables - Naomi/Hikari/facebook app secret" -FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id" \ No newline at end of file +FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id" +LINKEDIN_CLIENT_ID="op://Environment Variables - Naomi/Hikari/linkedin client id" +LINKEDIN_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/linkedin client secret" \ No newline at end of file diff --git a/server/src/modules/announceOnBluesky.ts b/server/src/modules/announceOnBluesky.ts index 7852379..d8473d6 100644 --- a/server/src/modules/announceOnBluesky.ts +++ b/server/src/modules/announceOnBluesky.ts @@ -11,6 +11,7 @@ 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: Array, ): Promise => { @@ -39,19 +40,35 @@ export const announceOnBluesky = async( if (typeof blueskyRequest === "string") { return `Failed to send initial post to Bluesky. ${blueskyRequest}`; } - let { uri } = blueskyRequest; + 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({ - replyTo: uri, - text: 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") { - const { uri: replyUri } = blueskyResponse; - uri = replyUri; + if (typeof blueskyResponse === "string") { + failedReplies.push(post); continue; } - failedReplies.push(post); + 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(", ")}` -- 2.52.0 From 6d89b2772b5edf8e2e5372d9e8a791f25f410a5a Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 15:48:34 -0800 Subject: [PATCH 06/11] chore: lint --- server/src/modules/announceOnTwitter.ts | 85 +++++++++++---------- server/src/modules/generateAnnouncements.ts | 3 +- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/server/src/modules/announceOnTwitter.ts b/server/src/modules/announceOnTwitter.ts index 153ea5f..2c5c5b7 100644 --- a/server/src/modules/announceOnTwitter.ts +++ b/server/src/modules/announceOnTwitter.ts @@ -11,49 +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: Array): Promise => { - if ( - process.env.TWITTER_CONSUMER_KEY === undefined +export const announceOnTwitter + = async(content: Array): Promise => { + 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 [ firstPost, ...restOfPosts ] = content; - const failedReplies: Array = []; - 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.`}`; -}; + const [ firstPost, ...restOfPosts ] = content; + const failedReplies: Array = []; + 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.`}`; + }; diff --git a/server/src/modules/generateAnnouncements.ts b/server/src/modules/generateAnnouncements.ts index 32bb17e..fd74483 100644 --- a/server/src/modules/generateAnnouncements.ts +++ b/server/src/modules/generateAnnouncements.ts @@ -11,7 +11,8 @@ import { announcementSystemMessage, } from "../config/announcements.js"; import { getAiCost } from "../utils/getAiCost.js"; -import type { AnnouncementResponse } from "../interfaces/announcementResponse.js"; +import type { AnnouncementResponse } + from "../interfaces/announcementResponse.js"; /** * Generates announcements for all platforms using AI. -- 2.52.0 From 9edf95078db17cf78703d548596f19c32f3fb477 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 16:14:52 -0800 Subject: [PATCH 07/11] feat: post to mastodon --- server/dev.env | 6 +- server/prod.env | 4 +- server/src/config/announcements.ts | 20 ++++ server/src/interfaces/announcementResponse.ts | 1 + server/src/modules/announceOnMastodon.ts | 96 +++++++++++++++++++ server/src/routes/announcement.ts | 5 +- 6 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 server/src/modules/announceOnMastodon.ts diff --git a/server/dev.env b/server/dev.env index 7ff4411..d3efc65 100644 --- a/server/dev.env +++ b/server/dev.env @@ -18,4 +18,8 @@ SANCTION_WEBHOOK="op://Environment Variables - Naomi/Hikari/sanction_webhook" FACEBOOK_PAGE_TOKEN="op://Environment Variables - Naomi/Hikari/facebook page token" FACEBOOK_APP_ID="op://Environment Variables - Naomi/Hikari/facebook app id" FACEBOOK_APP_SECRET="op://Environment Variables - Naomi/Hikari/facebook app secret" -FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id" \ No newline at end of file +FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id" +LINKEDIN_CLIENT_ID="op://Environment Variables - Naomi/Hikari/linkedin client id" +LINKEDIN_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/linkedin client secret" +MASTODON_INSTANCE_URL="op://Environment Variables - Naomi/Hikari/mastodon url" +MASTODON_ACCESS_TOKEN="op://Environment Variables - Naomi/Hikari/mastodon token" \ No newline at end of file diff --git a/server/prod.env b/server/prod.env index 4e46978..d3efc65 100644 --- a/server/prod.env +++ b/server/prod.env @@ -20,4 +20,6 @@ FACEBOOK_APP_ID="op://Environment Variables - Naomi/Hikari/facebook app id" FACEBOOK_APP_SECRET="op://Environment Variables - Naomi/Hikari/facebook app secret" FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id" LINKEDIN_CLIENT_ID="op://Environment Variables - Naomi/Hikari/linkedin client id" -LINKEDIN_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/linkedin client secret" \ No newline at end of file +LINKEDIN_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/linkedin client secret" +MASTODON_INSTANCE_URL="op://Environment Variables - Naomi/Hikari/mastodon url" +MASTODON_ACCESS_TOKEN="op://Environment Variables - Naomi/Hikari/mastodon token" \ No newline at end of file diff --git a/server/src/config/announcements.ts b/server/src/config/announcements.ts index d9b501f..9ecad51 100644 --- a/server/src/config/announcements.ts +++ b/server/src/config/announcements.ts @@ -39,6 +39,15 @@ Platform-specific requirements: - Make the first post compelling to encourage thread reading - Do NOT include post numbers or thread indicators +**Mastodon:** +- Break content into a thread of individual posts +- Each post should be under 500 characters (Mastodon's limit) +- Posts should flow naturally from one to the next +- Use relevant hashtags (2-3 per post) +- Make the first post compelling to encourage thread reading +- Do NOT include post numbers or thread indicators +- Mastodon supports markdown, so you can use basic formatting like **bold** and *italic* + **Facebook:** - Plain text format (no markdown) - Professional yet friendly tone @@ -98,6 +107,16 @@ const announcementJsonSchema = { description: "Plain text announcement for LinkedIn with professional hashtags. Should maintain Hikari's personality while being slightly more formal. Focus on value proposition and impact. Include calls to action for donating and joining Discord.", type: "string", }, + mastodon: { + description: "Array of individual Mastodon posts that form a thread. Each post should be under 500 characters and flow naturally from one to the next. Mastodon supports markdown formatting.", + items: { + description: "A single Mastodon post in the thread (max 500 characters, no post numbers or thread indicators)", + maxLength: 500, + type: "string", + }, + minItems: 1, + type: "array", + }, reddit: { additionalProperties: false, description: "Reddit announcement with title and markdown-formatted content", @@ -131,6 +150,7 @@ const announcementJsonSchema = { "discord", "facebook", "linkedin", + "mastodon", "reddit", "twitter", ], diff --git a/server/src/interfaces/announcementResponse.ts b/server/src/interfaces/announcementResponse.ts index c0d1738..0dcdb02 100644 --- a/server/src/interfaces/announcementResponse.ts +++ b/server/src/interfaces/announcementResponse.ts @@ -16,6 +16,7 @@ export interface AnnouncementResponse { }; facebook: string; linkedin: string; + mastodon: Array; reddit: { content: string; title: string; diff --git a/server/src/modules/announceOnMastodon.ts b/server/src/modules/announceOnMastodon.ts new file mode 100644 index 0000000..c5db593 --- /dev/null +++ b/server/src/modules/announceOnMastodon.ts @@ -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, +): Promise => { + 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 = []; + 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.`}`; +}; diff --git a/server/src/routes/announcement.ts b/server/src/routes/announcement.ts index 81febf5..41ce4e7 100644 --- a/server/src/routes/announcement.ts +++ b/server/src/routes/announcement.ts @@ -9,6 +9,7 @@ import { database } from "../db/database.js"; import { announceOnBluesky } from "../modules/announceOnBluesky.js"; import { announceOnDiscord } from "../modules/announceOnDiscord.js"; import { announceOnFacebook } from "../modules/announceOnFacebook.js"; +import { announceOnMastodon } from "../modules/announceOnMastodon.js"; import { announceOnReddit } from "../modules/announceOnReddit.js"; import { announceOnTwitter } from "../modules/announceOnTwitter.js"; import { generateAnnouncements } from "../modules/generateAnnouncements.js"; @@ -91,6 +92,7 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { bluesky, discord, facebook, + mastodon, reddit, twitter, } = announcement.response; @@ -118,9 +120,10 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { const blueskyPost = await announceOnBluesky(bluesky); const twitterPost = await announceOnTwitter(twitter); const facebookPost = await announceOnFacebook(facebook); + const mastodonPost = await announceOnMastodon(mastodon); return await reply.status(201).send({ cost: announcement.cost, - message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}`, + message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}, Mastodon: ${mastodonPost}`, rawPost: announcement.response, }); }, -- 2.52.0 From 0dba2483701f7630490aed758fe4221afef7a8e9 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 19:02:08 -0800 Subject: [PATCH 08/11] feat: add announcements on threads --- server/dev.env | 5 +- server/package.json | 3 +- server/prod.env | 5 +- server/src/config/announcements.ts | 20 + server/src/interfaces/announcementResponse.ts | 1 + server/src/modules/announceOnThreads.ts | 190 ++++++ server/src/routes/announcement.ts | 5 +- server/threadsAuth.js | 604 ++++++++++++++++++ 8 files changed, 829 insertions(+), 4 deletions(-) create mode 100644 server/src/modules/announceOnThreads.ts create mode 100644 server/threadsAuth.js diff --git a/server/dev.env b/server/dev.env index d3efc65..6c13cd4 100644 --- a/server/dev.env +++ b/server/dev.env @@ -22,4 +22,7 @@ FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id" LINKEDIN_CLIENT_ID="op://Environment Variables - Naomi/Hikari/linkedin client id" LINKEDIN_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/linkedin client secret" MASTODON_INSTANCE_URL="op://Environment Variables - Naomi/Hikari/mastodon url" -MASTODON_ACCESS_TOKEN="op://Environment Variables - Naomi/Hikari/mastodon token" \ No newline at end of file +MASTODON_ACCESS_TOKEN="op://Environment Variables - Naomi/Hikari/mastodon token" +THREADS_APP_ID="op://Environment Variables - Naomi/Hikari/threads app id" +THREADS_APP_SECRET="op://Environment Variables - Naomi/Hikari/threads app secret" +THREADS_ACCESS_TOKEN= \ No newline at end of file diff --git a/server/package.json b/server/package.json index 0910e2b..a7424de 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,8 @@ "start": "op run --env-file=./prod.env -- node ./prod/index.js", "test": "echo 'No tests yet' && exit 0", "facebookAuth": "op run --env-file=./prod.env -- node facebookAuth.js", - "linkedinAuth": "op run --env-file=./prod.env -- node linkedinAuth.js" + "linkedinAuth": "op run --env-file=./prod.env -- node linkedinAuth.js", + "threadsAuth": "op run --env-file=./prod.env -- node threadsAuth.js" }, "keywords": [], "author": "", diff --git a/server/prod.env b/server/prod.env index d3efc65..6c13cd4 100644 --- a/server/prod.env +++ b/server/prod.env @@ -22,4 +22,7 @@ FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id" LINKEDIN_CLIENT_ID="op://Environment Variables - Naomi/Hikari/linkedin client id" LINKEDIN_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/linkedin client secret" MASTODON_INSTANCE_URL="op://Environment Variables - Naomi/Hikari/mastodon url" -MASTODON_ACCESS_TOKEN="op://Environment Variables - Naomi/Hikari/mastodon token" \ No newline at end of file +MASTODON_ACCESS_TOKEN="op://Environment Variables - Naomi/Hikari/mastodon token" +THREADS_APP_ID="op://Environment Variables - Naomi/Hikari/threads app id" +THREADS_APP_SECRET="op://Environment Variables - Naomi/Hikari/threads app secret" +THREADS_ACCESS_TOKEN= \ No newline at end of file diff --git a/server/src/config/announcements.ts b/server/src/config/announcements.ts index 9ecad51..5062239 100644 --- a/server/src/config/announcements.ts +++ b/server/src/config/announcements.ts @@ -48,6 +48,15 @@ Platform-specific requirements: - Do NOT include post numbers or thread indicators - Mastodon supports markdown, so you can use basic formatting like **bold** and *italic* +**Threads:** +- Break content into a thread of individual posts +- Each post should be under 500 characters (Threads' limit) +- Posts should flow naturally from one to the next +- Use relevant hashtags (2-3 per post) +- Make the first post compelling to encourage thread reading +- Do NOT include post numbers or thread indicators +- Plain text format (no markdown) + **Facebook:** - Plain text format (no markdown) - Professional yet friendly tone @@ -134,6 +143,16 @@ const announcementJsonSchema = { required: [ "content", "title" ], type: "object", }, + threads: { + description: "Array of individual Threads posts that form a thread. Each post should be under 500 characters and flow naturally from one to the next.", + items: { + description: "A single Threads post in the thread (max 500 characters, no post numbers or thread indicators)", + maxLength: 500, + type: "string", + }, + minItems: 1, + type: "array", + }, twitter: { description: "Array of individual Twitter posts that form a thread. Each post should be under 280 characters and flow naturally from one to the next.", items: { @@ -152,6 +171,7 @@ const announcementJsonSchema = { "linkedin", "mastodon", "reddit", + "threads", "twitter", ], type: "object", diff --git a/server/src/interfaces/announcementResponse.ts b/server/src/interfaces/announcementResponse.ts index 0dcdb02..8ddbd8c 100644 --- a/server/src/interfaces/announcementResponse.ts +++ b/server/src/interfaces/announcementResponse.ts @@ -21,5 +21,6 @@ export interface AnnouncementResponse { content: string; title: string; }; + threads: Array; twitter: Array; } diff --git a/server/src/modules/announceOnThreads.ts b/server/src/modules/announceOnThreads.ts new file mode 100644 index 0000000..7a328a9 --- /dev/null +++ b/server/src/modules/announceOnThreads.ts @@ -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, +): Promise => { + if ( + process.env.THREADS_ACCESS_TOKEN === undefined + ) { + return "Threads credentials are not set."; + } + const [ firstPost, ...restOfPosts ] = content; + const failedReplies: Array = []; + 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.`}`; +}; diff --git a/server/src/routes/announcement.ts b/server/src/routes/announcement.ts index 41ce4e7..5dee9b6 100644 --- a/server/src/routes/announcement.ts +++ b/server/src/routes/announcement.ts @@ -11,6 +11,7 @@ import { announceOnDiscord } from "../modules/announceOnDiscord.js"; import { announceOnFacebook } from "../modules/announceOnFacebook.js"; import { announceOnMastodon } from "../modules/announceOnMastodon.js"; import { announceOnReddit } from "../modules/announceOnReddit.js"; +import { announceOnThreads } from "../modules/announceOnThreads.js"; import { announceOnTwitter } from "../modules/announceOnTwitter.js"; import { generateAnnouncements } from "../modules/generateAnnouncements.js"; import { getIpFromRequest } from "../modules/getIpFromRequest.js"; @@ -94,6 +95,7 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { facebook, mastodon, reddit, + threads, twitter, } = announcement.response; const { title: discordTitle, content: discordContent } = discord; @@ -121,9 +123,10 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { const twitterPost = await announceOnTwitter(twitter); const facebookPost = await announceOnFacebook(facebook); const mastodonPost = await announceOnMastodon(mastodon); + const threadsPost = await announceOnThreads(threads); return await reply.status(201).send({ cost: announcement.cost, - message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}, Mastodon: ${mastodonPost}`, + message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}, Mastodon: ${mastodonPost}, Threads: ${threadsPost}`, rawPost: announcement.response, }); }, diff --git a/server/threadsAuth.js b/server/threadsAuth.js new file mode 100644 index 0000000..660b6b1 --- /dev/null +++ b/server/threadsAuth.js @@ -0,0 +1,604 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + * + * Simple local server to authenticate with Threads (via Meta/Facebook) and obtain an Access Token. + * Run with: node threadsAuth.js + * Make sure to set THREADS_APP_ID and THREADS_APP_SECRET environment variables. + * + * Note: You need an Instagram Business Account linked to your Threads profile. + * The OAuth flow goes through Facebook's endpoints (Meta's unified platform) but uses + * Threads-specific app credentials. + */ + +import http from "http"; +import { URL } from "url"; + +const PORT = 3001; // Different port from Facebook auth +// Threads API requires HTTPS for OAuth redirects +// For local development, use ngrok: ngrok http 3001 +// Then set THREADS_REDIRECT_URI to your ngrok HTTPS URL +const REDIRECT_URI =`https://local3001.nhcarrigan.com/callback`; + +/** + * Creates the Threads OAuth authorization URL. + * Threads uses its own OAuth endpoint: threads.net/oauth/authorize + * @param {string} appId - The Threads App ID. + * @returns {string} The authorization URL. + */ +const getAuthUrl = (appId) => { + const params = new URLSearchParams({ + client_id: appId, + redirect_uri: REDIRECT_URI, + scope: "threads_basic,threads_content_publish", + response_type: "code", + }); + return `https://threads.net/oauth/authorize?${params.toString()}`; +}; + +/** + * Exchanges an authorization code for an access token. + * Threads uses its own token endpoint: graph.threads.net/oauth/access_token + * @param {string} code - The authorization code from Threads. + * @param {string} appId - The Threads App ID. + * @param {string} appSecret - The Threads App Secret. + * @returns {Promise<{access_token: string, user_id?: number}>} The access token response. + */ +const exchangeCodeForToken = async (code, appId, appSecret) => { + const params = new URLSearchParams({ + client_id: appId, + client_secret: appSecret, + redirect_uri: REDIRECT_URI, + code: code, + grant_type: "authorization_code", + }); + + const response = await fetch( + `https://graph.threads.net/oauth/access_token`, + { + body: params, + method: "POST", + }, + ); + return await response.json(); +}; + +/** + * Exchanges a short-lived token for a long-lived token. + * @param {string} shortLivedToken - The short-lived access token. + * @param {string} appId - The Threads App ID. + * @param {string} appSecret - The Threads App Secret. + * @returns {Promise<{access_token: string, expires_in?: number}>} The long-lived token response. + */ +const exchangeForLongLivedToken = async (shortLivedToken, appId, appSecret) => { + const params = new URLSearchParams({ + grant_type: "fb_exchange_token", + client_id: appId, + client_secret: appSecret, + fb_exchange_token: shortLivedToken, + }); + + const response = await fetch( + `https://graph.facebook.com/v21.0/oauth/access_token?${params.toString()}`, + ); + return await response.json(); +}; + +/** + * Gets the user's Instagram Business Accounts. + * @param {string} accessToken - The user access token. + * @returns {Promise} Array of Instagram Business Accounts. + */ +const getInstagramAccounts = async (accessToken) => { + const response = await fetch( + `https://graph.facebook.com/v21.0/me/accounts?fields=instagram_business_account&access_token=${accessToken}`, + ); + const data = await response.json(); + const accounts = []; + + if (data.data) { + for (const page of data.data) { + if (page.instagram_business_account) { + const igAccountResponse = await fetch( + `https://graph.facebook.com/v21.0/${page.instagram_business_account.id}?fields=id,username,threads_profile&access_token=${accessToken}`, + ); + const igAccount = await igAccountResponse.json(); + if (igAccount.threads_profile) { + accounts.push({ + instagramAccountId: igAccount.id, + username: igAccount.username, + threadsProfileId: igAccount.threads_profile.id, + }); + } + } + } + } + + return accounts; +}; + +/** + * Sends an HTML response. + * @param {http.ServerResponse} res - The HTTP response object. + * @param {number} statusCode - The HTTP status code. + * @param {string} html - The HTML content to send. + */ +const sendHtml = (res, statusCode, html) => { + res.writeHead(statusCode, { "Content-Type": "text/html" }); + res.end(html); +}; + +/** + * Sends a JSON response. + * @param {http.ServerResponse} res - The HTTP response object. + * @param {number} statusCode - The HTTP status code. + * @param {object} data - The JSON data to send. + */ +const sendJson = (res, statusCode, data) => { + res.writeHead(statusCode, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data, null, 2)); +}; + +const appId = process.env.THREADS_APP_ID?.trim(); +const appSecret = process.env.THREADS_APP_SECRET?.trim(); + +if (!appId || !appSecret) { + console.error( + "Error: THREADS_APP_ID and THREADS_APP_SECRET environment variables must be set.", + ); + console.error( + "Example: THREADS_APP_ID=your_app_id THREADS_APP_SECRET=your_secret node threadsAuth.js", + ); + process.exit(1); +} + +// Validate App ID format (should be numeric) +if (!/^\d+$/.test(appId)) { + console.error( + `Error: THREADS_APP_ID does not appear to be valid. Got: "${appId}"`, + ); + console.error( + "App ID should be a numeric string. Make sure you're using 'op run' to resolve 1Password references.", + ); + console.error( + "Run: pnpm threadsAuth (or: op run --env-file=./prod.env -- node threadsAuth.js)", + ); + process.exit(1); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + + // Root route - show auth link + if (url.pathname === "/") { + const authUrl = getAuthUrl(appId); + const html = ` + + + + Threads Token Generator + + + +
+

๐Ÿ” Threads Token Generator

+

Click the button below to authenticate with Meta/Facebook and get your Threads Access Token.

+ Authenticate with Meta +
+ Note: You need: +
    +
  • An Instagram Business Account
  • +
  • A Threads profile linked to that Instagram account
  • +
  • Admin access to a Facebook Page connected to your Instagram Business Account
  • +
+
+
+ โš ๏ธ Important: Your Threads app must have: +
    +
  • Threads API product added
  • +
  • threads_basic and threads_content_publish permissions approved
  • +
  • Valid OAuth Redirect URI: ${REDIRECT_URI}
  • +
+
+ ${REDIRECT_URI.startsWith("http://") ? ` +
+ ๐Ÿ”’ HTTPS Required: Threads API requires HTTPS for OAuth redirects! +
    +
  • Install cloudflared: brew install cloudflared or download from cloudflare.com
  • +
  • Run: cloudflared tunnel --url http://localhost:${PORT}
  • +
  • Copy the HTTPS URL (e.g., https://abc123.trycloudflare.com)
  • +
  • Set environment variable: THREADS_REDIRECT_URI=https://abc123.trycloudflare.com/callback
  • +
  • Add the HTTPS URL to your Threads app's Valid OAuth Redirect URIs
  • +
  • Restart this server
  • +
+
+ ` : ""} +
+ + + `; + return sendHtml(res, 200, html); + } + + // Callback route - handle OAuth callback + if (url.pathname === "/callback") { + // Threads appends #_ to the redirect URI - strip it from the URL + let code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + const errorReason = url.searchParams.get("error_reason"); + const errorDescription = url.searchParams.get("error_description"); + + // Debug: Log the full callback URL to see what Threads is sending + console.log(`\n๐Ÿ” Callback received:`); + console.log(` Full URL: ${url.href}`); + console.log(` Expected redirect URI: ${REDIRECT_URI}`); + console.log(` Error: ${error || "none"}`); + console.log(` Error reason: ${errorReason || "none"}`); + console.log(` Error description: ${errorDescription || "none"}\n`); + + // If code is in the hash (after #_), extract it + if (!code && url.hash) { + const hashParams = new URLSearchParams(url.hash.substring(1)); + code = hashParams.get("code"); + } + + if (error) { + const html = ` + + + + Authentication Error + + + +
+

โŒ Authentication Error

+
+

Error: ${error}

+

Error Reason: ${errorReason || "N/A"}

+

Error Description: ${errorDescription || "N/A"}

+

Full Callback URL: ${url.href}

+

Expected Redirect URI: ${REDIRECT_URI}

+
+

Try again

+
+ + + `; + return sendHtml(res, 400, html); + } + + if (!code) { + return sendHtml( + res, + 400, + "

Error

No authorization code received.

Try again", + ); + } + + try { + // Step 1: Exchange code for access token + const tokenResponse = await exchangeCodeForToken(code, appId, appSecret); + + if (tokenResponse.error_type || tokenResponse.error_message) { + throw new Error( + tokenResponse.error_message || "Failed to exchange code for token", + ); + } + + if (!tokenResponse.access_token) { + throw new Error( + "No access token received. Response: " + JSON.stringify(tokenResponse), + ); + } + + const accessToken = tokenResponse.access_token; + const userId = tokenResponse.user_id; + + // Step 2: Get Instagram Business Account ID + // The user_id from Threads token exchange is the Instagram Business Account ID + // We can also verify this by calling the Threads API + const accounts = []; + if (userId) { + // Try to get account info from Threads API + try { + const accountInfoResponse = await fetch( + `https://graph.threads.net/v1.0/${userId}?fields=id,username&access_token=${accessToken}`, + ); + if (accountInfoResponse.ok) { + const accountInfo = await accountInfoResponse.json(); + accounts.push({ + instagramAccountId: userId.toString(), + username: accountInfo.username || "unknown", + threadsProfileId: userId.toString(), // Threads Profile ID is same as Instagram Business Account ID + }); + } else { + // Fallback: use the user_id as Instagram Business Account ID + accounts.push({ + instagramAccountId: userId.toString(), + username: "unknown", + threadsProfileId: userId.toString(), + }); + } + } catch (err) { + // Fallback: use the user_id as Instagram Business Account ID + accounts.push({ + instagramAccountId: userId.toString(), + username: "unknown", + threadsProfileId: userId.toString(), + }); + } + } + + if (accounts.length === 0) { + return sendHtml( + res, + 200, + ` + + + + No Threads Accounts Found + + + +
+

โš ๏ธ No Threads Accounts Found

+

You don't have access to any Instagram Business Accounts with Threads profiles, or your Facebook Page isn't connected to an Instagram Business Account.

+

Try again

+
+ + + `, + ); + } + + // Display results + const accountsHtml = accounts + .map( + (account) => ` +
+

@${account.username}

+

Instagram Business Account ID: ${account.instagramAccountId}

+

Threads Profile ID: ${account.threadsProfileId}

+

Access Token:

+ +

Note: Threads access tokens are short-lived. You may need to refresh them periodically.

+
+ `, + ) + .join(""); + + const html = ` + + + + Success! Your Threads Tokens + + + +
+

โœ… Success!

+
+

Your Threads Access Tokens:

+

Copy these values and add them to your environment variables.

+
+ ${accountsHtml} +
+

โš ๏ธ Important:

+
    +
  • Store these tokens securely (like your other API credentials)
  • +
  • Add the access token to your environment variables as THREADS_ACCESS_TOKEN
  • +
  • Add the Instagram Business Account ID as THREADS_INSTAGRAM_ACCOUNT_ID
  • +
  • Add the Threads Profile ID as THREADS_PROFILE_ID (usually same as Instagram Account ID)
  • +
  • Threads tokens are short-lived and may need to be refreshed periodically
  • +
+
+

Start over

+
+ + + `; + + return sendHtml(res, 200, html); + } catch (error) { + const html = ` + + + + Error + + + +
+

โŒ Error

+
+

Error: ${error.message}

+
+

Try again

+
+ + + `; + return sendHtml(res, 500, html); + } + } + + // 404 + sendHtml(res, 404, "

Not Found

Go home

"); +}); + +server.listen(PORT, () => { + console.log(`\n๐Ÿš€ Threads Auth Server running at http://localhost:${PORT}`); + console.log(`\n๐Ÿ“‹ Make sure you've set:`); + console.log(` - THREADS_APP_ID`); + console.log(` - THREADS_APP_SECRET`); + + if (REDIRECT_URI.startsWith("http://")) { + console.log(`\n๐Ÿ”’ HTTPS REQUIRED: Threads API requires HTTPS for OAuth redirects!`); + console.log(`\n Current redirect URI: ${REDIRECT_URI}`); + console.log(`\n To fix:`); + console.log(` 1. Install cloudflared: brew install cloudflared`); + console.log(` 2. Run: cloudflared tunnel --url http://localhost:${PORT}`); + console.log(` 3. Copy the HTTPS URL (e.g., https://abc123.trycloudflare.com)`); + console.log(` 4. Set: THREADS_REDIRECT_URI=https://abc123.trycloudflare.com/callback`); + console.log(` 5. Add the HTTPS URL to your Threads app's Valid OAuth Redirect URIs`); + console.log(` 6. Restart this server`); + } else { + console.log(`\nโœ… Using HTTPS redirect URI: ${REDIRECT_URI}`); + } + + console.log(`\n๐Ÿ”— Open http://localhost:${PORT} in your browser to start!`); + console.log(`\nโš ๏ธ Make sure your Threads app has:`); + console.log(` - Threads API product added`); + console.log(` - threads_basic and threads_content_publish permissions`); + console.log(` - OAuth Redirect URI: ${REDIRECT_URI}`); + console.log(` - Client OAuth Login: ON`); + console.log(` - Web OAuth Login: ON`); + console.log(`\n๐Ÿ’ก Note: OAuth flow uses Threads-specific endpoints`); + console.log(`\n๐Ÿ” Debug info:`); + console.log(` - Redirect URI: ${REDIRECT_URI}`); + console.log(` - URL-encoded: ${encodeURIComponent(REDIRECT_URI)}`); + console.log(` - Make sure this EXACTLY matches what's in your Threads app settings\n`); +}); + -- 2.52.0 From e010b4904b5719fee9147b732cc92bb14bbc2277 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 19:40:25 -0800 Subject: [PATCH 09/11] feat: clean up prompt schema into formats instead of platforms --- server/src/config/announcements.ts | 132 ++++-------------- server/src/interfaces/announcementResponse.ts | 14 +- server/src/routes/announcement.ts | 38 +++-- 3 files changed, 45 insertions(+), 139 deletions(-) diff --git a/server/src/config/announcements.ts b/server/src/config/announcements.ts index 5062239..f7f2a96 100644 --- a/server/src/config/announcements.ts +++ b/server/src/config/announcements.ts @@ -16,60 +16,30 @@ Your personality traits: Platform-specific requirements: -**Discord & Reddit:** +**Markdown (for Discord, Reddit, Ko-fi, and Patreon):** - Use markdown formatting (bold, italic, links, lists, etc.) - Include engaging titles that capture attention - Write full, detailed content that tells the complete story - Do NOT use hashtags (these platforms don't use them effectively) - Include clear calls to action +- The same content will be used for Discord, Reddit, Ko-fi, and Patreon, so make it work well for all these platforms -**Twitter:** +**Threaded (for Threads, Twitter, Bluesky, and Mastodon):** - Break content into a thread of individual posts -- Each post should be under 280 characters +- Each post should be under 280 characters (to work for Twitter's limit, which is the most restrictive) - Posts should flow naturally from one to the next - Use relevant hashtags (2-3 per post maximum) - Make the first post compelling to encourage thread reading - Do NOT include post numbers or thread indicators (e.g., "1/5" or "๐Ÿงต") - -**BlueSky:** -- Break content into a thread of individual posts -- Each post should be under 300 characters -- Posts should flow naturally from one to the next -- Use relevant hashtags sparingly (1-2 per post) -- Make the first post compelling to encourage thread reading -- Do NOT include post numbers or thread indicators - -**Mastodon:** -- Break content into a thread of individual posts -- Each post should be under 500 characters (Mastodon's limit) -- Posts should flow naturally from one to the next -- Use relevant hashtags (2-3 per post) -- Make the first post compelling to encourage thread reading -- Do NOT include post numbers or thread indicators -- Mastodon supports markdown, so you can use basic formatting like **bold** and *italic* - -**Threads:** -- Break content into a thread of individual posts -- Each post should be under 500 characters (Threads' limit) -- Posts should flow naturally from one to the next -- Use relevant hashtags (2-3 per post) -- Make the first post compelling to encourage thread reading -- Do NOT include post numbers or thread indicators - Plain text format (no markdown) +- The same thread will be used for Threads, Twitter, Bluesky, and Mastodon -**Facebook:** +**Plaintext (for LinkedIn, Facebook, and Peerlist):** - Plain text format (no markdown) -- Professional yet friendly tone -- Include relevant hashtags (3-5 total) -- Write in a conversational style suitable for a broader audience +- Professional yet friendly tone, conversational style suitable for a broader audience +- Include 3-5 relevant hashtags - Keep it concise but informative - -**LinkedIn:** -- Plain text format (no markdown) -- More professional tone while maintaining Hikari's personality -- Include relevant professional hashtags (3-5 total) -- Focus on value proposition and impact -- Slightly more formal than other platforms but still engaging +- The same content will be used for LinkedIn, Facebook, and Peerlist **Universal requirements:** - All announcements must include a call to action to donate (https://donate.nhcarrigan.com) @@ -80,83 +50,36 @@ Platform-specific requirements: const announcementJsonSchema = { additionalProperties: false, properties: { - bluesky: { - description: "Array of individual BlueSky posts that form a thread. Each post should be under 300 characters and flow naturally from one to the next.", - items: { - description: "A single BlueSky post in the thread (max 300 characters, no post numbers or thread indicators)", - maxLength: 300, - type: "string", - }, - minItems: 1, - type: "array", - }, - discord: { + markdown: { additionalProperties: false, - description: "Discord announcement with title and markdown-formatted content", + description: "Markdown-formatted announcement for Discord, Reddit, Ko-fi, and Patreon (shared content)", properties: { content: { - description: "Full announcement content formatted with markdown (bold, italic, links, lists, etc.). Should include calls to action for donating and joining Discord.", + description: "Full announcement content formatted with markdown (bold, italic, links, lists, etc.). Should include calls to action for donating and joining Discord. Will be used for Discord, Reddit, Ko-fi, and Patreon.", maxLength: 1900, + minLength: 100, type: "string", }, title: { - description: "Engaging title for the Discord announcement (should capture attention and summarize the key point)", + description: "Engaging title for the announcement (should capture attention and summarize the key point). Will be used for Discord, Reddit, Ko-fi, and Patreon.", maxLength: 256, + minLength: 25, type: "string", }, }, required: [ "content", "title" ], type: "object", }, - facebook: { - description: "Plain text announcement for Facebook with relevant hashtags. Should be conversational and suitable for a broader audience. Include calls to action for donating and joining Discord.", + plaintext: { + description: "Plain text announcement for LinkedIn, Facebook, and Peerlist (shared content). Should be professional yet friendly, conversational style suitable for a broader audience. Include 3-5 relevant hashtags and calls to action for donating and joining Discord.", + maxLength: 1900, + minLength: 100, type: "string", }, - linkedin: { - description: "Plain text announcement for LinkedIn with professional hashtags. Should maintain Hikari's personality while being slightly more formal. Focus on value proposition and impact. Include calls to action for donating and joining Discord.", - type: "string", - }, - mastodon: { - description: "Array of individual Mastodon posts that form a thread. Each post should be under 500 characters and flow naturally from one to the next. Mastodon supports markdown formatting.", + threaded: { + description: "Array of individual posts that form a thread. Will be used for Threads, Twitter, Bluesky, and Mastodon. Each post should be under 280 characters (Twitter's limit) and flow naturally from one to the next.", items: { - description: "A single Mastodon post in the thread (max 500 characters, no post numbers or thread indicators)", - maxLength: 500, - type: "string", - }, - minItems: 1, - type: "array", - }, - reddit: { - additionalProperties: false, - description: "Reddit announcement with title and markdown-formatted content", - properties: { - content: { - description: "Full announcement content formatted with markdown (bold, italic, links, lists, etc.). Should include calls to action for donating and joining Discord.", - type: "string", - }, - title: { - description: "Engaging title for the Reddit post (should be clear, informative, and follow Reddit title conventions)", - maxLength: 256, - type: "string", - }, - }, - required: [ "content", "title" ], - type: "object", - }, - threads: { - description: "Array of individual Threads posts that form a thread. Each post should be under 500 characters and flow naturally from one to the next.", - items: { - description: "A single Threads post in the thread (max 500 characters, no post numbers or thread indicators)", - maxLength: 500, - type: "string", - }, - minItems: 1, - type: "array", - }, - twitter: { - description: "Array of individual Twitter posts that form a thread. Each post should be under 280 characters and flow naturally from one to the next.", - items: { - description: "A single Twitter post in the thread (max 280 characters, no post numbers or thread indicators)", + description: "A single post in the thread (max 280 characters, no post numbers or thread indicators)", maxLength: 280, type: "string", }, @@ -165,14 +88,9 @@ const announcementJsonSchema = { }, }, required: [ - "bluesky", - "discord", - "facebook", - "linkedin", - "mastodon", - "reddit", - "threads", - "twitter", + "markdown", + "plaintext", + "threaded", ], type: "object", }; diff --git a/server/src/interfaces/announcementResponse.ts b/server/src/interfaces/announcementResponse.ts index 8ddbd8c..6c58499 100644 --- a/server/src/interfaces/announcementResponse.ts +++ b/server/src/interfaces/announcementResponse.ts @@ -9,18 +9,10 @@ * @see {@link announcementJsonSchema} */ export interface AnnouncementResponse { - bluesky: Array; - discord: { + markdown: { content: string; title: string; }; - facebook: string; - linkedin: string; - mastodon: Array; - reddit: { - content: string; - title: string; - }; - threads: Array; - twitter: Array; + plaintext: string; + threaded: Array; } diff --git a/server/src/routes/announcement.ts b/server/src/routes/announcement.ts index 5dee9b6..178d865 100644 --- a/server/src/routes/announcement.ts +++ b/server/src/routes/announcement.ts @@ -90,43 +90,39 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { } const { - bluesky, - discord, - facebook, - mastodon, - reddit, - threads, - twitter, + markdown, + plaintext, + threaded, } = announcement.response; - const { title: discordTitle, content: discordContent } = discord; - const { title: redditTitle, content: redditContent } = reddit; + const { title: markdownTitle, content: markdownContent } = markdown; await database.getInstance().announcements.create({ data: { - content: discordContent, - title: discordTitle, + content: markdownContent, + title: markdownTitle, type: type, }, }); const discordPost = await announceOnDiscord( - discordTitle, - discordContent, + markdownTitle, + markdownContent, type, ); const redditPost = await announceOnReddit( - redditTitle, - redditContent, + markdownTitle, + markdownContent, type, ); - const blueskyPost = await announceOnBluesky(bluesky); - const twitterPost = await announceOnTwitter(twitter); - const facebookPost = await announceOnFacebook(facebook); - const mastodonPost = await announceOnMastodon(mastodon); - const threadsPost = await announceOnThreads(threads); + const blueskyPost = await announceOnBluesky(threaded); + const twitterPost = await announceOnTwitter(threaded); + const facebookPost = await announceOnFacebook(plaintext); + const threadsPost = await announceOnThreads(threaded); + const mastodonPost = await announceOnMastodon(threaded); return await reply.status(201).send({ + alert: `Please remember to manually post to: LinkedIn, Peerlist, Ko-fi, and Patreon.`, cost: announcement.cost, - message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}, Mastodon: ${mastodonPost}, Threads: ${threadsPost}`, + message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}, Threads: ${threadsPost}, Mastodon: ${mastodonPost}`, rawPost: announcement.response, }); }, -- 2.52.0 From 971fcae8e313a1ec2bb582c07ce09bfdb20679e3 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 19:45:26 -0800 Subject: [PATCH 10/11] fix(ci): gotta generate prisma client --- .gitea/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index da6aa1a..2d684ac 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: - name: Install Dependencies run: pnpm install + - name: Build Prisma + run: cd server && pnpm prisma generate + - name: Lint Source Files run: pnpm run lint -- 2.52.0 From 26929482024df0ee2951e0c3540ae45de4acb09e Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Mon, 5 Jan 2026 14:08:23 -0800 Subject: [PATCH 11/11] feat: helper to generate proper syntax for everyone ping --- server/src/modules/announceOnDiscord.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/server/src/modules/announceOnDiscord.ts b/server/src/modules/announceOnDiscord.ts index 5b886cd..dbfd4c2 100644 --- a/server/src/modules/announceOnDiscord.ts +++ b/server/src/modules/announceOnDiscord.ts @@ -12,15 +12,15 @@ const channelIds: Record = { company: "1422472775695728661", products: "1386105452881776661", }; -const roleIds: Record = { +const roleIds: Record, string> = { community: "1386107941224054895", + products: "1386107909699666121", +}; - /** - * Note that this is not a role ID, but the server ID. - * Company announcements ping everyone. - */ - company: "1354624415861833870", - products: "1386107909699666121", +const getAnnouncementPing = (type: AnnouncementType): string => { + return type === "company" + ? "@everyone" + : `<@&${roleIds[type]}>`; }; /** @@ -40,7 +40,7 @@ export const announceOnDiscord = async( { 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 ?? ""}`, -- 2.52.0