From 1b8a866548b99cd001a047d8d40b76dfa4eb31ad Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Tue, 17 Feb 2026 22:20:51 -0800 Subject: [PATCH] Update Threads client --- .env.example | 6 +- app/social/clients/ThreadsClient.mjs | 191 ++++++++++++++++++++++++--- app/social/index.mjs | 4 +- package-lock.json | 135 ------------------- package.json | 1 - 5 files changed, 174 insertions(+), 163 deletions(-) diff --git a/.env.example b/.env.example index 2a8e911..32cbda4 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,6 @@ BLUESKY_SERVICE=https://bsky.social BLUESKY_IDENTIFIER=splatoon3.ink # Handle or email address BLUESKY_PASSWORD= -# Threads API parameters -THREADS_USERNAME= -THREADS_PASSWORD= +# Threads API parameters (https://developers.facebook.com/docs/threads) +THREADS_USER_ID= +THREADS_ACCESS_TOKEN= diff --git a/app/social/clients/ThreadsClient.mjs b/app/social/clients/ThreadsClient.mjs index f8af2f8..ff77ea7 100644 --- a/app/social/clients/ThreadsClient.mjs +++ b/app/social/clients/ThreadsClient.mjs @@ -1,31 +1,63 @@ import sharp from 'sharp'; -import threads from 'threads-api'; +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import Client from './Client.mjs'; +import ValueCache from '../../common/ValueCache.mjs'; export default class ThreadsClient extends Client { key = 'threads'; name = 'Threads'; - #api; - - constructor() { - super(); - - this.#api = new threads.ThreadsAPI({ - username: process.env.THREADS_USERNAME, - // password: process.env.THREADS_PASSWORD, - token: process.env.THREADS_TOKEN, - deviceID: process.env.THREADS_DEVICE_ID, - }); - } + #baseUrl = 'https://graph.threads.net/v1.0'; + #tokenCache = new ValueCache('threads.token'); + #accessToken; async canSend() { - // return process.env.THREADS_USERNAME - // && process.env.THREADS_PASSWORD; + return process.env.THREADS_USER_ID + && process.env.THREADS_ACCESS_TOKEN + && process.env.AWS_S3_BUCKET + && process.env.AWS_S3_ENDPOINT; + } - return process.env.THREADS_USERNAME - && process.env.THREADS_TOKEN - && process.env.THREADS_DEVICE_ID; + async #getAccessToken() { + if (this.#accessToken) { + return this.#accessToken; + } + + // Try to use a previously refreshed token from cache + let cached = await this.#tokenCache.getData(); + if (cached) { + this.#accessToken = cached; + return this.#accessToken; + } + + // Fall back to the .env token + this.#accessToken = process.env.THREADS_ACCESS_TOKEN; + return this.#accessToken; + } + + async #refreshToken() { + let currentToken = await this.#getAccessToken(); + + let params = new URLSearchParams({ + grant_type: 'th_refresh_token', + access_token: currentToken, + }); + + let response = await fetch(`${this.#baseUrl}/refresh_access_token?${params}`); + let data = await response.json(); + + if (data.error) { + console.error(`[${this.name}] Failed to refresh token:`, data.error.message); + return; + } + + this.#accessToken = data.access_token; + + // Cache the token with its expiry + let expires = new Date(Date.now() + data.expires_in * 1000); + await this.#tokenCache.setData(data.access_token, expires); + + console.log(`[${this.name}] Token refreshed, expires ${expires.toISOString()}`); } async send(status, generator) { @@ -35,15 +67,130 @@ export default class ThreadsClient extends Client { } try { + // Refresh the token if it hasn't been refreshed in the last 24 hours + let lastRefresh = await this.#tokenCache.getCachedAt(); + let oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + if (!lastRefresh || lastRefresh < oneDayAgo) { + await this.#refreshToken(); + } + + let accessToken = await this.#getAccessToken(); let jpeg = await sharp(status.media[0].file).jpeg().toBuffer(); - await this.#api.publish({ - text: status.status, - image: { type: 'image/jpeg', data: jpeg }, - }); + // Upload image to S3 so it's publicly accessible + let imageUrl = await this.#uploadImage(jpeg, generator.key); + + // Create a media container + let containerId = await this.#createContainer(status.status, imageUrl, accessToken); + + // Wait for the container to finish processing + await this.#waitForContainer(containerId, accessToken); + + // Publish the container + await this.#publish(containerId, accessToken); } catch (error) { console.error(`[${this.name}] Failed to post ${generator.key}:`, error.message); throw error; } } + + async #uploadImage(buffer, key) { + let s3 = new S3Client({ + endpoint: process.env.AWS_S3_ENDPOINT, + region: process.env.AWS_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + + let s3Key = `status-screenshots/${key}.jpg`; + + await s3.send(new PutObjectCommand({ + Bucket: process.env.AWS_S3_BUCKET, + Key: s3Key, + Body: buffer, + ContentType: 'image/jpeg', + ACL: 'public-read', + })); + + // Construct the public URL (path-style to avoid SSL issues with dotted bucket names) + return `${process.env.AWS_S3_ENDPOINT}/${process.env.AWS_S3_BUCKET}/${s3Key}`; + } + + async #createContainer(text, imageUrl, accessToken) { + let userId = process.env.THREADS_USER_ID; + let params = new URLSearchParams({ + media_type: 'IMAGE', + image_url: imageUrl, + text, + access_token: accessToken, + }); + + let response = await fetch(`${this.#baseUrl}/${userId}/threads`, { + method: 'POST', + body: params, + }); + + let data = await response.json(); + + if (data.error) { + throw new Error(`${data.error.message} (type: ${data.error.type}, code: ${data.error.code}, fbtrace_id: ${data.error.fbtrace_id})`); + } + + if (!data.id) { + throw new Error(`Unexpected response: ${JSON.stringify(data)}`); + } + + return data.id; + } + + async #waitForContainer(containerId, accessToken) { + // Poll container status until it's ready (or timeout after 60s) + let maxAttempts = 12; + + for (let i = 0; i < maxAttempts; i++) { + let params = new URLSearchParams({ + fields: 'status', + access_token: accessToken, + }); + + let response = await fetch(`${this.#baseUrl}/${containerId}?${params}`); + let data = await response.json(); + + if (data.status === 'FINISHED') { + return; + } + + if (data.status === 'ERROR') { + throw new Error(`Container processing failed: ${JSON.stringify(data)}`); + } + + // Wait 5 seconds before checking again + await new Promise(resolve => setTimeout(resolve, 5000)); + } + + throw new Error('Container processing timed out'); + } + + async #publish(containerId, accessToken) { + let userId = process.env.THREADS_USER_ID; + let params = new URLSearchParams({ + creation_id: containerId, + access_token: accessToken, + }); + + let response = await fetch(`${this.#baseUrl}/${userId}/threads_publish`, { + method: 'POST', + body: params, + }); + + let data = await response.json(); + + if (data.error) { + throw new Error(data.error.message); + } + + return data.id; + } } diff --git a/app/social/index.mjs b/app/social/index.mjs index 9ab0940..c7fa897 100644 --- a/app/social/index.mjs +++ b/app/social/index.mjs @@ -17,7 +17,7 @@ import EggstraWorkStatus from './generators/EggstraWorkStatus.mjs'; import EggstraWorkUpcomingStatus from './generators/EggstraWorkUpcomingStatus.mjs'; import BlueskyClient from './clients/BlueskyClient.mjs'; import ChallengeStatus from './generators/ChallengeStatus.mjs'; -// import ThreadsClient from './clients/ThreadsClient.mjs'; +import ThreadsClient from './clients/ThreadsClient.mjs'; function defaultStatusGenerators() { return [ @@ -46,7 +46,7 @@ function defaultClients() { // new TwitterClient, new MastodonClient, new BlueskyClient, - // new ThreadsClient, + new ThreadsClient, new ImageWriter, ]; } diff --git a/package-lock.json b/package-lock.json index 9864db9..ec33a84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "s3-sync-client": "^4.3.1", "sharp": "^0.34.5", "sirv": "^3.0.2", - "threads-api": "^1.4.0", "twitter-api-v2": "^1.29.0", "vue": "^3.5.28", "vue-i18n": "^11.2.8", @@ -5819,11 +5818,6 @@ "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, "node_modules/await-lock": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", @@ -6349,17 +6343,6 @@ "node": ">=0.1.90" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/comment-parser": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", @@ -6553,14 +6536,6 @@ "node": ">= 14" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6819,21 +6794,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -7551,41 +7511,6 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7847,20 +7772,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -8861,14 +8772,6 @@ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, - "node_modules/mrmime": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", - "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11030,44 +10933,6 @@ "b4a": "^1.6.4" } }, - "node_modules/threads-api": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/threads-api/-/threads-api-1.6.3.tgz", - "integrity": "sha512-5ytREfUM4IVBn2vgxnaet/iyF8ux8Wc6+liV+WaJ/nHICoSdPjk9Iecpj7Yes7OpE0dhRtfokZy1z4h1d3Bfqw==", - "dependencies": { - "axios": "^1.4.0", - "mrmime": "^1.0.1", - "uuid": "^9.0.0" - }, - "bin": { - "threads-api": "bin/cli.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/threads-api/node_modules/axios": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", - "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/threads-api/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index cc483a9..fef5e56 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "s3-sync-client": "^4.3.1", "sharp": "^0.34.5", "sirv": "^3.0.2", - "threads-api": "^1.4.0", "twitter-api-v2": "^1.29.0", "vue": "^3.5.28", "vue-i18n": "^11.2.8",