automated social posting (#2022)

* social posting without message selection

* message formatting and scheduled job

* small cleanup
This commit is contained in:
Keyan 2025-03-25 18:32:30 -05:00 committed by GitHub
parent 1921c253ba
commit 8a6b825659
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 173 additions and 0 deletions

View File

@ -32,6 +32,12 @@ SLACK_CHANNEL_ID=
LNAUTH_URL=http://localhost:3000/api/lnauth LNAUTH_URL=http://localhost:3000/api/lnauth
LNWITH_URL=http://localhost:3000/api/lnwith 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 # # SNDEV STUFF WE PRESET #
# which you can override in .env.local # # which you can override in .env.local #

6
package-lock.json generated
View File

@ -91,6 +91,7 @@
"textarea-caret": "^3.1.0", "textarea-caret": "^3.1.0",
"tldts": "^6.1.51", "tldts": "^6.1.51",
"tsx": "^4.19.1", "tsx": "^4.19.1",
"twitter-api-v2": "^1.22.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"unzipper": "^0.12.3", "unzipper": "^0.12.3",
"url-unshort": "^6.1.0", "url-unshort": "^6.1.0",
@ -19632,6 +19633,11 @@
"resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz",
"integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" "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": { "node_modules/type": {
"version": "2.7.3", "version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",

View File

@ -96,6 +96,7 @@
"textarea-caret": "^3.1.0", "textarea-caret": "^3.1.0",
"tldts": "^6.1.51", "tldts": "^6.1.51",
"tsx": "^4.19.1", "tsx": "^4.19.1",
"twitter-api-v2": "^1.22.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"unzipper": "^0.12.3", "unzipper": "^0.12.3",
"url-unshort": "^6.1.0", "url-unshort": "^6.1.0",

View File

@ -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;

View File

@ -599,6 +599,7 @@ model Item {
invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull)
PollBlindVote PollBlindVote[] PollBlindVote PollBlindVote[]
ItemUserAgg ItemUserAgg[] ItemUserAgg ItemUserAgg[]
AutoSocialPost AutoSocialPost[]
@@index([uploadId]) @@index([uploadId])
@@index([lastZapAt]) @@index([lastZapAt])
@ -649,6 +650,18 @@ model ItemUserAgg {
@@index([createdAt]) @@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 // this is a denomalized table that is used to make reply notifications
// more efficient ... it is populated by a trigger when replies are created // more efficient ... it is populated by a trigger when replies are created
model Reply { model Reply {

View File

@ -37,6 +37,7 @@ import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts'
import { expireBoost } from './expireBoost' import { expireBoost } from './expireBoost'
import { payingActionConfirmed, payingActionFailed } from './payingAction' import { payingActionConfirmed, payingActionFailed } from './payingAction'
import { autoDropBolt11s } from './autoDropBolt11' import { autoDropBolt11s } from './autoDropBolt11'
import { postToSocial } from './socialPoster'
// WebSocket polyfill // WebSocket polyfill
import ws from 'isomorphic-ws' import ws from 'isomorphic-ws'
@ -142,6 +143,7 @@ async function work () {
await boss.work('saltAndHashEmails', jobWrapper(saltAndHashEmails)) await boss.work('saltAndHashEmails', jobWrapper(saltAndHashEmails))
await boss.work('reminder', jobWrapper(remindUser)) await boss.work('reminder', jobWrapper(remindUser))
await boss.work('thisDay', jobWrapper(thisDay)) await boss.work('thisDay', jobWrapper(thisDay))
await boss.work('socialPoster', jobWrapper(postToSocial))
console.log('working jobs') console.log('working jobs')
} }

109
worker/socialPoster.js Normal file
View File

@ -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 })
}
}