From 8a6b8256592845c85248252fb76b9543c37f0fda Mon Sep 17 00:00:00 2001 From: Keyan <34140557+huumn@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:32:30 -0500 Subject: [PATCH] automated social posting (#2022) * social posting without message selection * message formatting and scheduled job * small cleanup --- .env.development | 6 + package-lock.json | 6 + package.json | 1 + .../migration.sql | 36 ++++++ prisma/schema.prisma | 13 +++ worker/index.js | 2 + worker/socialPoster.js | 109 ++++++++++++++++++ 7 files changed, 173 insertions(+) create mode 100644 prisma/migrations/20250325224718_auto_social_poster/migration.sql create mode 100644 worker/socialPoster.js diff --git a/.env.development b/.env.development index 41925214..7f6af164 100644 --- a/.env.development +++ b/.env.development @@ -32,6 +32,12 @@ SLACK_CHANNEL_ID= LNAUTH_URL=http://localhost:3000/api/lnauth LNWITH_URL=http://localhost:3000/api/lnwith +# auto social poster +TWITTER_POSTER_API_KEY= +TWITTER_POSTER_API_KEY_SECRET= +TWITTER_POSTER_ACCESS_TOKEN= +TWITTER_POSTER_ACCESS_TOKEN_SECRET= + ######################################## # SNDEV STUFF WE PRESET # # which you can override in .env.local # diff --git a/package-lock.json b/package-lock.json index 31c843a4..e073bed2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,6 +91,7 @@ "textarea-caret": "^3.1.0", "tldts": "^6.1.51", "tsx": "^4.19.1", + "twitter-api-v2": "^1.22.0", "unist-util-visit": "^5.0.0", "unzipper": "^0.12.3", "url-unshort": "^6.1.0", @@ -19632,6 +19633,11 @@ "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" }, + "node_modules/twitter-api-v2": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/twitter-api-v2/-/twitter-api-v2-1.22.0.tgz", + "integrity": "sha512-KlcRL9vcBzjeS/PwxX33NziP+SHp9n35DOclKtpOmnNes7nNVnK7WG4pKlHfBqGrY5kAz/8J5ERS8DWkYOaiWw==" + }, "node_modules/type": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", diff --git a/package.json b/package.json index f7e0ae80..4a81f087 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "textarea-caret": "^3.1.0", "tldts": "^6.1.51", "tsx": "^4.19.1", + "twitter-api-v2": "^1.22.0", "unist-util-visit": "^5.0.0", "unzipper": "^0.12.3", "url-unshort": "^6.1.0", diff --git a/prisma/migrations/20250325224718_auto_social_poster/migration.sql b/prisma/migrations/20250325224718_auto_social_poster/migration.sql new file mode 100644 index 00000000..ee58fc68 --- /dev/null +++ b/prisma/migrations/20250325224718_auto_social_poster/migration.sql @@ -0,0 +1,36 @@ +-- CreateTable +CREATE TABLE "AutoSocialPost" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "itemId" INTEGER NOT NULL, + + CONSTRAINT "AutoSocialPost_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AutoSocialPost_itemId_idx" ON "AutoSocialPost"("itemId"); + +-- CreateIndex +CREATE INDEX "AutoSocialPost_created_at_idx" ON "AutoSocialPost"("created_at"); + +-- AddForeignKey +ALTER TABLE "AutoSocialPost" ADD CONSTRAINT "AutoSocialPost_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE OR REPLACE FUNCTION schedule_social_poster_job() +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE +BEGIN + -- every 60th minute + INSERT INTO pgboss.schedule (name, cron, timezone) + VALUES ('socialPoster', '*/60 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING; + return 0; +EXCEPTION WHEN OTHERS THEN + return 0; +END; +$$; + +SELECT schedule_social_poster_job(); +DROP FUNCTION IF EXISTS schedule_social_poster_job; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6fafca91..6346a0dc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -599,6 +599,7 @@ model Item { invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) PollBlindVote PollBlindVote[] ItemUserAgg ItemUserAgg[] + AutoSocialPost AutoSocialPost[] @@index([uploadId]) @@index([lastZapAt]) @@ -649,6 +650,18 @@ model ItemUserAgg { @@index([createdAt]) } +// record auto social posts so we can avoid duplicates +model AutoSocialPost { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + itemId Int + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + + @@index([itemId]) + @@index([createdAt]) +} + // this is a denomalized table that is used to make reply notifications // more efficient ... it is populated by a trigger when replies are created model Reply { diff --git a/worker/index.js b/worker/index.js index dfb6c4f9..34c1ef91 100644 --- a/worker/index.js +++ b/worker/index.js @@ -37,6 +37,7 @@ import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts' import { expireBoost } from './expireBoost' import { payingActionConfirmed, payingActionFailed } from './payingAction' import { autoDropBolt11s } from './autoDropBolt11' +import { postToSocial } from './socialPoster' // WebSocket polyfill import ws from 'isomorphic-ws' @@ -142,6 +143,7 @@ async function work () { await boss.work('saltAndHashEmails', jobWrapper(saltAndHashEmails)) await boss.work('reminder', jobWrapper(remindUser)) await boss.work('thisDay', jobWrapper(thisDay)) + await boss.work('socialPoster', jobWrapper(postToSocial)) console.log('working jobs') } diff --git a/worker/socialPoster.js b/worker/socialPoster.js new file mode 100644 index 00000000..77cb418f --- /dev/null +++ b/worker/socialPoster.js @@ -0,0 +1,109 @@ +import Nostr from '@/lib/nostr' +import { TwitterApi } from 'twitter-api-v2' +import { msatsToSats, numWithUnits } from '@/lib/format' + +async function postToTwitter ({ message }) { + if (!process.env.TWITTER_POSTER_API_KEY || + !process.env.TWITTER_POSTER_API_KEY_SECRET || + !process.env.TWITTER_POSTER_ACCESS_TOKEN || + !process.env.TWITTER_POSTER_ACCESS_TOKEN_SECRET) { + console.log('Twitter poster not configured') + return + } + + try { + const client = new TwitterApi({ + appKey: process.env.TWITTER_POSTER_API_KEY, + appSecret: process.env.TWITTER_POSTER_API_KEY_SECRET, + accessToken: process.env.TWITTER_POSTER_ACCESS_TOKEN, + accessSecret: process.env.TWITTER_POSTER_ACCESS_TOKEN_SECRET + }) + await client.appLogin() + await client.v2.tweet(message) + console.log('Successfully posted to Twitter') + } catch (err) { + console.error('Error posting to Twitter:', err) + } +} + +const RELAYS = [ + 'wss://nos.lol/', + 'wss://nostr.land/', + 'wss://nostr.wine/', + 'wss://purplerelay.com/', + 'wss://relay.damus.io/', + 'wss://relay.snort.social/', + 'wss://relay.nostr.band/', + 'wss://relay.primal.net/' +] + +async function postToNostr ({ message }) { + if (!process.env.NOSTR_PRIVATE_KEY) { + console.log('Nostr poster not configured') + return + } + + const nostr = Nostr.get() + const signer = nostr.getSigner({ privKey: process.env.NOSTR_PRIVATE_KEY }) + try { + await nostr.publish({ + created_at: Math.floor(new Date().getTime() / 1000), + content: message, + tags: [], + kind: 1 + }, { + relays: RELAYS, + signer, + timeout: 5000 + }) + } catch (err) { + console.error('Error posting to Nostr:', err) + } +} + +async function getHottestItem ({ models }) { + const item = await models.$queryRaw` + SELECT "Item".*, users.name as "userName" + FROM "Item" + JOIN hot_score_view ON "Item"."id" = hot_score_view.id + JOIN users ON "Item"."userId" = users.id + LEFT JOIN "AutoSocialPost" ON "Item"."id" = "AutoSocialPost"."itemId" + WHERE "AutoSocialPost"."id" IS NULL + AND "Item"."parentId" IS NULL + AND NOT "Item".bio + AND "Item"."deletedAt" IS NULL + ORDER BY "hot_score_view"."hot_score" DESC + LIMIT 1` + + if (item.length === 0) { + console.log('No item to post') + return null + } + + await models.AutoSocialPost.create({ + data: { + itemId: item[0].id + } + }) + + return item[0] +} + +async function itemToMessage ({ item }) { + return `${item.title} + +by ${item.userName} to ~${item.subName} +${numWithUnits(msatsToSats(item.msats), { abbreviate: false })} so far + +https://stacker.news/items/${item.id}/r/sn` +} + +export async function postToSocial ({ models }) { + const item = await getHottestItem({ models }) + if (item) { + const message = await itemToMessage({ item }) + console.log('Message:', message) + await postToTwitter({ message }) + await postToNostr({ message }) + } +}