diff --git a/app.vue b/app.vue index 7573f99..130e34b 100644 --- a/app.vue +++ b/app.vue @@ -65,11 +65,16 @@ function newThread() { flex-direction: column; } -.app-brand {} +.app-brand { + border-bottom: 1px solid lightgray; + display: flex; + align-items: center; + height: 60px; +} .app-name { font-weight: 800; - font-size: 2rem; + font-size: 1.5rem; margin: 0.5rem; } diff --git a/components/ThreadEditor.vue b/components/ThreadEditor.vue index f5d0aa2..1b658fa 100644 --- a/components/ThreadEditor.vue +++ b/components/ThreadEditor.vue @@ -95,7 +95,7 @@ async function publishThread(): Promise { if (thread.value && thread.value.messages) { thread.value.id = await doSaveThread(thread.value) const nonEmptyMessages = thread.value.messages.filter((message: any) => message.text.trim().length > 0 || message.attachments.length > 0) - await $fetch(`/api/threads/${thread.value.id}/publication`, { method: 'post', body: { messages: nonEmptyMessages } }) + await $fetch(`/api/threads/${thread.value.id}/publication`, { method: 'post' }) toast.add({ severity: 'success', summary: 'Thread published', detail: `${nonEmptyMessages.length} posts published`, life: 3000 }); } } @@ -127,13 +127,13 @@ async function deleteThread(): Promise {

ID: {{ thread.id }}

-
-
-
diff --git a/components/ThreadSummaryList.vue b/components/ThreadSummaryList.vue index 2616fcc..13c76c5 100644 --- a/components/ThreadSummaryList.vue +++ b/components/ThreadSummaryList.vue @@ -4,7 +4,6 @@ const emit = defineEmits<{ }>() const selectThread = (threadId: number) => { - console.log(`Clicked item ${threadId}`) emit('threadSelected', threadId); } diff --git a/server/api/publications.post.ts b/server/api/publications.post.ts deleted file mode 100644 index dca4ffc..0000000 --- a/server/api/publications.post.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { createRestAPIClient, mastodon } from 'masto' -import AtProtocole from "@atproto/api"; -import { ReplyRef } from '@atproto/api/dist/client/types/app/bsky/feed/post'; -import { SendTweetV2Params, TweetV2PostTweetResult, TwitterApi } from 'twitter-api-v2'; - -function getEnvString(key: string): string { - return process.env[key] || 'undefined' -} - -// Social network API clients - -let blueskyClient: AtProtocole.BskyAgent - -let mastodonClient: mastodon.rest.Client - -let twitterClient: TwitterApi; - -async function connectClients(): Promise { - if (!blueskyClient) { - console.log('Connect to Bluesky…') - blueskyClient = new AtProtocole.BskyAgent({ service: getEnvString('BLUESKY_URL') }) - await blueskyClient.login({ - identifier: getEnvString('BLUESKY_IDENTIFIER'), - password: getEnvString('BLUESKY_PASSWORD'), - }); - console.log('Connection to Bluesky acquired.') - } - - if (!mastodonClient) { - console.log('Connect to Mastodon…') - mastodonClient = createRestAPIClient({ - url: getEnvString('MASTODON_URL'), - accessToken: getEnvString('MASTODON_ACCESS_TOKEN') - }) - console.log('Connection to Mastodon acquired.') - } - if (!twitterClient) { - console.log('Connect to Twitter…') - twitterClient = new TwitterApi({ - appKey: getEnvString('TWITTER_CONSUMER_KEY'), - appSecret: getEnvString('TWITTER_CONSUMER_SECRET'), - accessToken: getEnvString('TWITTER_ACCESS_TOKEN'), - accessSecret: getEnvString('TWITTER_ACCESS_SECRET') - }); - console.log('Connection to Twitter acquired.') - } -} - -// Publication on Mastodon - -async function postMessageOnMastodon(message: Message, inReplyToId: string | null): Promise { - const mediaIds: string[] = [] - if (message.attachments) { - for (const attachment of message.attachments) { - const remoteFile = await fetch(attachment.location); - const media = await mastodonClient.v2.media.create({ - file: await remoteFile.blob(), - description: attachment.alt, - }); - mediaIds.push(media.id) - } - } - - return mastodonClient.v1.statuses.create({ - status: message.text, - visibility: 'public', - mediaIds, - inReplyToId - }); -} - -async function postMessagesOnMastodon(messages: Message[]): Promise { - let inReplyToId: string | null = null - - for (const message of messages) { - console.log('Publish message on Mastodon…') - const status: mastodon.v1.Status = await postMessageOnMastodon(message, inReplyToId); - inReplyToId = status.id - console.log('Message published on Mastodon.') - } -} - -// Publication on Bluesky - -interface RecordRef { - uri: string; - cid: string; -} - -async function postMessageOnBluesky(message: Message, reply: ReplyRef | null): Promise { - try { - - const record: any = {} - record.text = message.text - - if (message.attachments && message.attachments.length > 0) { - let embed: any - embed = { - $type: 'app.bsky.embed.images', - images: [] - } - for (const file of message.attachments) { - const mediaFile = await fetch(file.location); - const mediaData = await mediaFile.arrayBuffer(); - const mediaResponse = await blueskyClient.uploadBlob(Buffer.from(mediaData), { encoding: file.mimetype }); - embed.images.push({ image: mediaResponse.data.blob, alt: file.alt }) - } - record.embed = embed - } - - if (reply) { - record.reply = reply - } - - return blueskyClient.post(record) - } catch (error) { - console.error(error) - throw error - } -} - -async function postMessagesOnBluesky(messages: Message[]): Promise { - try { - let reply: ReplyRef | null = null - - for (const message of messages) { - console.log('Publish message on Bluesky') - const recordRef: RecordRef = await postMessageOnBluesky(message, reply); - reply = { - parent: { - cid: recordRef.cid, - uri: recordRef.uri - }, - root: { - cid: recordRef.cid, - uri: recordRef.uri - } - } - console.log('Message published on Bluesky.') - } - } catch (error) { - console.error(error) - throw error - } -} - -// Publication on Twitter - -async function postMessageOnTwitter(message: Message, reply: TweetV2PostTweetResult | null): Promise { - try { - const tweet: SendTweetV2Params = {} - if (message.text) { - tweet.text = message.text - } - if (message.attachments && message.attachments.length > 0) { - const mediaIds = [] - for (const file of message.attachments) { - const mediaResponse = await fetch(file.location); - const mediaData = await mediaResponse.arrayBuffer(); - const mediaId = await twitterClient.v1.uploadMedia(Buffer.from(mediaData), { mimeType: file.mimetype }) - mediaIds.push(mediaId) - } - tweet.media = { media_ids: mediaIds } - } - if (reply && reply.data) { - tweet.reply = { in_reply_to_tweet_id: reply.data.id } - } - return twitterClient.v2.tweet(tweet) - } catch (error) { - console.error(error) - throw error - } -} - -async function postMessagesOnTwitter(messages: Message[]): Promise { - let reply: TweetV2PostTweetResult | null = null - - for (const message of messages) { - console.log('Publish message on Twitter') - reply = await postMessageOnTwitter(message, reply); - console.log('Message published on twitter.') - } -} - -async function postMessages(messages: Message[]): Promise { - const platforms: string[] = [] - - if (process.env.BLUESKY_ENABLED as string === 'true') { - console.log('Publish messages on Bluesky') - await postMessagesOnBluesky(messages) - platforms.push('Bluesky') - console.log('Messages published on Bluesky.') - } - - if (process.env.MASTODON_ENABLED as string === 'true') { - console.log('Publish messages on Mastodon…') - await postMessagesOnMastodon(messages) - platforms.push('Mastodon') - console.log('Messages published on Mastodon.') - } - - if (process.env.TWITTER_ENABLED as string === 'true') { - console.log('Publish messages on Twitter…') - await postMessagesOnTwitter(messages) - platforms.push('Twitter') - console.log('Messages published on Twitter.') - } - return platforms -} - -interface MessageAttachment { - location: string; - data: any; - mimetype?: string; - alt?: string -} - -interface Message { - text: string; - attachments?: MessageAttachment[]; -} - -export default defineEventHandler(async (event) => { - console.log(`POST /api/publications`) - - const body = await readBody(event) - console.log('Publish thread…') - console.log(body) - await connectClients() - const platforms: string[] = await postMessages(body.messages) - let report = 'Thread published' - if (platforms.length === 1) { - report += ' on ' + platforms[0] - } - if (platforms.length > 1) { - const last = platforms.pop(); - report += ' on ' + platforms.join(', ') + ' and ' + last; - } - console.log(`${report} 🎉 !`) - return { body } -}) \ No newline at end of file diff --git a/server/api/threads/[id].get.ts b/server/api/threads/[id].get.ts index 02400d2..7583dc6 100644 --- a/server/api/threads/[id].get.ts +++ b/server/api/threads/[id].get.ts @@ -18,6 +18,5 @@ export default defineEventHandler(async (event: any) => { const [latest] = thread.versions.slice(-1) result.latest = latest } - console.log(result) return result; }) \ No newline at end of file diff --git a/server/api/threads/[id].post.ts b/server/api/threads/[id].post.ts index 4c9dc9d..7dfc244 100644 --- a/server/api/threads/[id].post.ts +++ b/server/api/threads/[id].post.ts @@ -45,6 +45,5 @@ export default defineEventHandler(async (event: any) => { const [latest] = thread.versions.slice(-1) result.latest = latest } - console.log(result) return result; }) \ No newline at end of file diff --git a/server/api/threads/[id]/publication.post.ts b/server/api/threads/[id]/publication.post.ts index 07b6194..bd4180d 100644 --- a/server/api/threads/[id]/publication.post.ts +++ b/server/api/threads/[id]/publication.post.ts @@ -1,9 +1,260 @@ +import { createRestAPIClient, mastodon } from 'masto' +import AtProtocole from "@atproto/api"; +import { ReplyRef } from '@atproto/api/dist/client/types/app/bsky/feed/post'; +import { SendTweetV2Params, TweetV2PostTweetResult, TwitterApi } from 'twitter-api-v2'; import { prisma } from '../../../../prisma/db' +function getEnvString(key: string): string { + return process.env[key] || 'undefined' +} + +// Social network API clients + +let blueskyClient: AtProtocole.BskyAgent + +let mastodonClient: mastodon.rest.Client + +let twitterClient: TwitterApi; + +async function connectClients(): Promise { + if (!blueskyClient) { + console.log('Connect to Bluesky…') + blueskyClient = new AtProtocole.BskyAgent({ service: getEnvString('BLUESKY_URL') }) + await blueskyClient.login({ + identifier: getEnvString('BLUESKY_IDENTIFIER'), + password: getEnvString('BLUESKY_PASSWORD'), + }); + console.log('Connection to Bluesky acquired.') + } + + if (!mastodonClient) { + console.log('Connect to Mastodon…') + mastodonClient = createRestAPIClient({ + url: getEnvString('MASTODON_URL'), + accessToken: getEnvString('MASTODON_ACCESS_TOKEN') + }) + console.log('Connection to Mastodon acquired.') + } + if (!twitterClient) { + console.log('Connect to Twitter…') + twitterClient = new TwitterApi({ + appKey: getEnvString('TWITTER_CONSUMER_KEY'), + appSecret: getEnvString('TWITTER_CONSUMER_SECRET'), + accessToken: getEnvString('TWITTER_ACCESS_TOKEN'), + accessSecret: getEnvString('TWITTER_ACCESS_SECRET') + }); + console.log('Connection to Twitter acquired.') + } +} + +// Publication on Mastodon + +async function postMessageOnMastodon(message: Message, inReplyToId: string | null): Promise { + const mediaIds: string[] = [] + if (message.attachments) { + for (const attachment of message.attachments) { + const remoteFile = await fetch(attachment.location); + const media = await mastodonClient.v2.media.create({ + file: await remoteFile.blob(), + description: attachment.alt, + }); + mediaIds.push(media.id) + } + } + + return mastodonClient.v1.statuses.create({ + status: message.text, + visibility: 'public', + mediaIds, + inReplyToId + }); +} + +async function postMessagesOnMastodon(messages: Message[]): Promise { + let inReplyToId: string | null = null + + for (const message of messages) { + console.log('Publish message on Mastodon…') + const status: mastodon.v1.Status = await postMessageOnMastodon(message, inReplyToId); + inReplyToId = status.id + console.log('Message published on Mastodon.') + } +} + +// Publication on Bluesky + +interface RecordRef { + uri: string; + cid: string; +} + +async function postMessageOnBluesky(message: Message, reply: ReplyRef | null): Promise { + try { + + const record: any = {} + record.text = message.text + + if (message.attachments && message.attachments.length > 0) { + let embed: any + embed = { + $type: 'app.bsky.embed.images', + images: [] + } + for (const file of message.attachments) { + const mediaFile = await fetch(file.location); + const mediaData = await mediaFile.arrayBuffer(); + const mediaResponse = await blueskyClient.uploadBlob(Buffer.from(mediaData), { encoding: file.mimetype }); + embed.images.push({ image: mediaResponse.data.blob, alt: file.alt }) + } + record.embed = embed + } + + if (reply) { + record.reply = reply + } + + return blueskyClient.post(record) + } catch (error) { + console.error(error) + throw error + } +} + +async function postMessagesOnBluesky(messages: Message[]): Promise { + try { + let reply: ReplyRef | null = null + + for (const message of messages) { + console.log('Publish message on Bluesky') + const recordRef: RecordRef = await postMessageOnBluesky(message, reply); + reply = { + parent: { + cid: recordRef.cid, + uri: recordRef.uri + }, + root: { + cid: recordRef.cid, + uri: recordRef.uri + } + } + console.log('Message published on Bluesky.') + } + } catch (error) { + console.error(error) + throw error + } +} + +// Publication on Twitter + +async function postMessageOnTwitter(message: Message, reply: TweetV2PostTweetResult | null): Promise { + try { + const tweet: SendTweetV2Params = {} + if (message.text) { + tweet.text = message.text + } + if (message.attachments && message.attachments.length > 0) { + const mediaIds = [] + for (const file of message.attachments) { + const mediaResponse = await fetch(file.location); + const mediaData = await mediaResponse.arrayBuffer(); + const mediaId = await twitterClient.v1.uploadMedia(Buffer.from(mediaData), { mimeType: file.mimetype }) + mediaIds.push(mediaId) + } + tweet.media = { media_ids: mediaIds } + } + if (reply && reply.data) { + tweet.reply = { in_reply_to_tweet_id: reply.data.id } + } + return twitterClient.v2.tweet(tweet) + } catch (error) { + console.error(error) + throw error + } +} + +async function postMessagesOnTwitter(messages: Message[]): Promise { + let reply: TweetV2PostTweetResult | null = null + + for (const message of messages) { + console.log('Publish message on Twitter') + reply = await postMessageOnTwitter(message, reply); + console.log('Message published on twitter.') + } +} + +async function postMessages(messages: Message[]): Promise { + const platforms: string[] = [] + + if (process.env.BLUESKY_ENABLED as string === 'true') { + console.log('Publish messages on Bluesky') + await postMessagesOnBluesky(messages) + platforms.push('Bluesky') + console.log('Messages published on Bluesky.') + } + + if (process.env.MASTODON_ENABLED as string === 'true') { + console.log('Publish messages on Mastodon…') + await postMessagesOnMastodon(messages) + platforms.push('Mastodon') + console.log('Messages published on Mastodon.') + } + + if (process.env.TWITTER_ENABLED as string === 'true') { + console.log('Publish messages on Twitter…') + await postMessagesOnTwitter(messages) + platforms.push('Twitter') + console.log('Messages published on Twitter.') + } + return platforms +} + +interface MessageAttachment { + location: string; + data: any; + mimetype?: string; + alt?: string +} + +interface Message { + text: string; + attachments?: MessageAttachment[]; +} + export default defineEventHandler(async (event: any) => { - const id = parseInt(event.context.params.id) as number + const threadId = parseInt(event.context.params.id) as number + + console.log(`POST /api/threads/${threadId}/publication`) + + const now = new Date() - console.log(`POST /api/threads/${id}/publication`) + const threadData = await prisma.thread.findFirst({ + where: { + id: threadId + }, + include: { + versions: true + } + }) + if (threadData) { + const [latestVersion] = threadData.versions.slice(-1) + const latestVersionData: any = latestVersion.data - return 'ok' + console.log('Publish thread…') + await connectClients() + const platforms: string[] = await postMessages(latestVersionData.messages) + let report = 'Thread published' + if (platforms.length === 1) { + report += ' on ' + platforms[0] + } + if (platforms.length > 1) { + const last = platforms.pop(); + report += ' on ' + platforms.join(', ') + ' and ' + last; + } + console.log(`${report} 🎉 !`) + return { + id: threadData.id, + messages: latestVersionData.messages + } + } }) \ No newline at end of file