automated social posting (#2022)
* social posting without message selection * message formatting and scheduled job * small cleanup
This commit is contained in:
		
							parent
							
								
									1921c253ba
								
							
						
					
					
						commit
						8a6b825659
					
				@ -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 #
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
@ -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 {
 | 
			
		||||
 | 
			
		||||
@ -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')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										109
									
								
								worker/socialPoster.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								worker/socialPoster.js
									
									
									
									
									
										Normal 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 })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user