From e29812501ea5fe22ce6626619a227b0104ca0d08 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sat, 17 Jan 2026 16:35:56 +0700 Subject: [PATCH] fix: urgent providers fix --- .../src/integrations/social/lemmy.provider.ts | 133 ++++++---- .../src/integrations/social/nostr.provider.ts | 91 +++++-- .../integrations/social/telegram.provider.ts | 251 +++++++++++------- .../src/integrations/social/vk.provider.ts | 247 +++++++++-------- 4 files changed, 429 insertions(+), 293 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts index 6234eb0f..465a0d86 100644 --- a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts @@ -10,7 +10,6 @@ import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { LemmySettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/lemmy.dto'; -import { groupBy } from 'lodash'; import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; export class LemmyProvider extends SocialAbstract implements SocialProvider { @@ -121,14 +120,7 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider { } } - async post( - id: string, - accessToken: string, - postDetails: PostDetails[], - integration: Integration - ): Promise { - const [firstPost, ...restPosts] = postDetails; - + private async getJwtAndService(integration: Integration): Promise<{ jwt: string; service: string }> { const body = JSON.parse( AuthService.fixedDecryption(integration.customInstanceDetails!) ); @@ -146,6 +138,18 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider { }) ).json(); + return { jwt, service: body.service }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [firstPost] = postDetails; + const { jwt, service } = await this.getJwtAndService(integration); + const valueArray: PostResponse[] = []; for (const lemmy of firstPost.settings.subreddit) { @@ -159,8 +163,8 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider { : {}), nsfw: false, }); - const { post_view, ...all } = await ( - await fetch(body.service + '/api/v3/post', { + const { post_view } = await ( + await fetch(service + '/api/v3/post', { body: JSON.stringify({ community_id: +lemmy.value.id, name: lemmy.value.title, @@ -188,41 +192,68 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider { valueArray.push({ postId: post_view.post.id, - releaseURL: body.service + '/post/' + post_view.post.id, + releaseURL: service + '/post/' + post_view.post.id, id: firstPost.id, status: 'published', }); - - for (const comment of restPosts) { - const { comment_view } = await ( - await fetch(body.service + '/api/v3/comment', { - body: JSON.stringify({ - post_id: post_view.post.id, - content: comment.message, - }), - method: 'POST', - headers: { - Authorization: `Bearer ${jwt}`, - 'Content-Type': 'application/json', - }, - }) - ).json(); - - valueArray.push({ - postId: comment_view.post.id, - releaseURL: body.service + '/comment/' + comment_view.comment.id, - id: comment.id, - status: 'published', - }); - } } - return Object.values(groupBy(valueArray, (p) => p.id)).map((p) => ({ - id: p[0].id, - postId: p.map((p) => String(p.postId)).join(','), - releaseURL: p.map((p) => p.releaseURL).join(','), - status: 'published', - })); + return [ + { + id: firstPost.id, + postId: valueArray.map((p) => String(p.postId)).join(','), + releaseURL: valueArray.map((p) => p.releaseURL).join(','), + status: 'published', + }, + ]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [commentPost] = postDetails; + const { jwt, service } = await this.getJwtAndService(integration); + + // postId can be comma-separated if posted to multiple communities + const postIds = postId.split(','); + const valueArray: PostResponse[] = []; + + for (const singlePostId of postIds) { + const { comment_view } = await ( + await fetch(service + '/api/v3/comment', { + body: JSON.stringify({ + post_id: +singlePostId, + content: commentPost.message, + }), + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + }) + ).json(); + + valueArray.push({ + postId: String(comment_view.comment.id), + releaseURL: service + '/comment/' + comment_view.comment.id, + id: commentPost.id, + status: 'published', + }); + } + + return [ + { + id: commentPost.id, + postId: valueArray.map((p) => p.postId).join(','), + releaseURL: valueArray.map((p) => p.releaseURL).join(','), + status: 'published', + }, + ]; } @Tool({ @@ -241,27 +272,11 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider { id: string, integration: Integration ) { - const body = JSON.parse( - AuthService.fixedDecryption(integration.customInstanceDetails!) - ); - - const { jwt } = await ( - await fetch(body.service + '/api/v3/user/login', { - body: JSON.stringify({ - username_or_email: body.identifier, - password: body.password, - }), - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }) - ).json(); + const { jwt, service } = await this.getJwtAndService(integration); const { communities } = await ( await fetch( - body.service + - `/api/v3/search?type_=Communities&sort=Active&q=${data.word}`, + service + `/api/v3/search?type_=Communities&sort=Active&q=${data.word}`, { headers: { Authorization: `Bearer ${jwt}`, diff --git a/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts index 8a7921e2..22c8a987 100644 --- a/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts @@ -11,6 +11,7 @@ import { getPublicKey, Relay, finalizeEvent, SimplePool } from 'nostr-tools'; import WebSocket from 'ws'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; +import { Integration } from '@prisma/client'; // @ts-ignore global.WebSocket = WebSocket; @@ -158,43 +159,77 @@ export class NostrProvider extends SocialAbstract implements SocialProvider { } } + private buildContent(post: PostDetails): string { + const mediaContent = post.media?.map((m) => m.path).join('\n\n') || ''; + return mediaContent + ? `${post.message}\n\n${mediaContent}` + : post.message; + } + async post( id: string, accessToken: string, postDetails: PostDetails[] ): Promise { const { password } = AuthService.verifyJWT(accessToken) as any; + const [firstPost] = postDetails; - let lastId = ''; - const ids: PostResponse[] = []; - for (const post of postDetails) { - const textEvent = finalizeEvent( - { - kind: 1, // Text note - content: - post.message + '\n\n' + post.media?.map((m) => m.path).join('\n\n'), - tags: [ - ...(lastId - ? [ - ['e', lastId, '', 'reply'], - ['p', id], - ] - : []), - ], // Include delegation token in the event - created_at: Math.floor(Date.now() / 1000), - }, - password - ); + const textEvent = finalizeEvent( + { + kind: 1, // Text note + content: this.buildContent(firstPost), + tags: [], + created_at: Math.floor(Date.now() / 1000), + }, + password + ); - lastId = await this.publish(id, textEvent); - ids.push({ - id: post.id, - postId: String(lastId), - releaseURL: `https://primal.net/e/${lastId}`, + const eventId = await this.publish(id, textEvent); + + return [ + { + id: firstPost.id, + postId: String(eventId), + releaseURL: `https://primal.net/e/${eventId}`, status: 'completed', - }); - } + }, + ]; + } - return ids; + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const { password } = AuthService.verifyJWT(accessToken) as any; + const [commentPost] = postDetails; + const replyToId = lastCommentId || postId; + + const textEvent = finalizeEvent( + { + kind: 1, // Text note + content: this.buildContent(commentPost), + tags: [ + ['e', replyToId, '', 'reply'], + ['p', id], + ], + created_at: Math.floor(Date.now() / 1000), + }, + password + ); + + const eventId = await this.publish(id, textEvent); + + return [ + { + id: commentPost.id, + postId: String(eventId), + releaseURL: `https://primal.net/e/${eventId}`, + status: 'completed', + }, + ]; } } diff --git a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts index ba07ba9b..9b1313bc 100644 --- a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts @@ -140,118 +140,173 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { : {}; } + private processMedia(mediaFiles: PostDetails['media']) { + return (mediaFiles || []).map((media) => { + let mediaUrl = media.path; + if (mediaStorage === 'local' && mediaUrl.startsWith(frontendURL)) { + mediaUrl = mediaUrl.replace(frontendURL, ''); + } + //get mime type to pass contentType to telegram api. + //some photos and videos might not pass telegram api restrictions, so they are sent as documents instead of returning errors + const mimeType = mime.getType(mediaUrl); // Detect MIME type + let mediaType: 'photo' | 'video' | 'document'; + + if (mimeType?.startsWith('image/')) { + mediaType = 'photo'; + } else if (mimeType?.startsWith('video/')) { + mediaType = 'video'; + } else { + mediaType = 'document'; + } + + return { + type: mediaType, + media: mediaUrl, + fileOptions: { + filename: media.path.split('/').pop(), + contentType: mimeType || 'application/octet-stream', + }, + }; + }); + } + + private async sendMessage( + accessToken: string, + message: PostDetails, + replyToMessageId?: number + ): Promise { + let messageId: number | null = null; + const mediaFiles = message.media || []; + const text = striptags(message.message || '', ['u', 'strong', 'p']) + .replace(//g, '') + .replace(/<\/strong>/g, '') + .replace(/

(.*?)<\/p>/g, '$1\n'); + + console.log(text); + const processedMedia = this.processMedia(mediaFiles); + + // if there's no media, bot sends a text message only + if (processedMedia.length === 0) { + const response = await telegramBot.sendMessage(accessToken, text, { + parse_mode: 'HTML', + ...(replyToMessageId ? { reply_to_message_id: replyToMessageId } : {}), + }); + messageId = response.message_id; + } + // if there's only one media, bot sends the media with the text message as caption + else if (processedMedia.length === 1) { + const media = processedMedia[0]; + const options = { + caption: text, + parse_mode: 'HTML' as const, + ...(replyToMessageId ? { reply_to_message_id: replyToMessageId } : {}), + }; + const response = + media.type === 'video' + ? await telegramBot.sendVideo( + accessToken, + media.media, + options, + media.fileOptions + ) + : media.type === 'photo' + ? await telegramBot.sendPhoto( + accessToken, + media.media, + options, + media.fileOptions + ) + : await telegramBot.sendDocument( + accessToken, + media.media, + options, + media.fileOptions + ); + messageId = response.message_id; + } + // if there are multiple media, bot sends them as a media group - max 10 media per group - with the text as a caption (if there are more than 1 group, the caption will only be sent with the first group) + else { + const mediaGroups = this.chunkMedia(processedMedia, 10); + for (let i = 0; i < mediaGroups.length; i++) { + const mediaGroup = mediaGroups[i].map((m, index) => ({ + type: m.type === 'document' ? 'document' : m.type, // Documents are not allowed in media groups + media: m.media, + caption: i === 0 && index === 0 ? text : undefined, + parse_mode: 'HTML', + })); + + const response = await telegramBot.sendMediaGroup( + accessToken, + mediaGroup as any[], + { + ...(replyToMessageId && i === 0 + ? { reply_to_message_id: replyToMessageId } + : {}), + } + ); + if (i === 0) { + messageId = response[0].message_id; + } + } + } + + return messageId; + } + async post( id: string, accessToken: string, postDetails: PostDetails[] ): Promise { - const ids: PostResponse[] = []; + const [firstPost] = postDetails; - for (const message of postDetails) { - let messageId: number | null = null; - const mediaFiles = message.media || []; - const text = striptags(message.message || '', ['u', 'strong', 'p']) - .replace(//g, '') - .replace(/<\/strong>/g, '') - .replace(/

(.*?)<\/p>/g, '$1\n'); + const messageId = await this.sendMessage(accessToken, firstPost); - console.log(text); - // check if media is local to modify url - const processedMedia = mediaFiles.map((media) => { - let mediaUrl = media.path; - if (mediaStorage === 'local' && mediaUrl.startsWith(frontendURL)) { - mediaUrl = mediaUrl.replace(frontendURL, ''); - } - //get mime type to pass contentType to telegram api. - //some photos and videos might not pass telegram api restrictions, so they are sent as documents instead of returning errors - const mimeType = mime.getType(mediaUrl); // Detect MIME type - let mediaType: 'photo' | 'video' | 'document'; - - if (mimeType?.startsWith('image/')) { - mediaType = 'photo'; - } else if (mimeType?.startsWith('video/')) { - mediaType = 'video'; - } else { - mediaType = 'document'; - } - - return { - type: mediaType, - media: mediaUrl, - fileOptions: { - filename: media.path.split('/').pop(), - contentType: mimeType || 'application/octet-stream', - }, - }; - }); - // if there's no media, bot sends a text message only - if (processedMedia.length === 0) { - const response = await telegramBot.sendMessage(accessToken, text, { - parse_mode: 'HTML', - }); - messageId = response.message_id; - } - // if there's only one media, bot sends the media with the text message as caption - else if (processedMedia.length === 1) { - const media = processedMedia[0]; - const response = - media.type === 'video' - ? await telegramBot.sendVideo( - accessToken, - media.media, - { caption: text, parse_mode: 'HTML' }, - media.fileOptions - ) - : media.type === 'photo' - ? await telegramBot.sendPhoto( - accessToken, - media.media, - { caption: text, parse_mode: 'HTML' }, - media.fileOptions - ) - : await telegramBot.sendDocument( - accessToken, - media.media, - { caption: text, parse_mode: 'HTML' }, - media.fileOptions - ); - messageId = response.message_id; - } - // if there are multiple media, bot sends them as a media group - max 10 media per group - with the text as a caption (if there are more than 1 group, the caption will only be sent with the first group) - else { - const mediaGroups = this.chunkMedia(processedMedia, 10); - for (let i = 0; i < mediaGroups.length; i++) { - const mediaGroup = mediaGroups[i].map((m, index) => ({ - type: m.type === 'document' ? 'document' : m.type, // Documents are not allowed in media groups - media: m.media, - caption: i === 0 && index === 0 ? text : undefined, - parse_mode: 'HTML', - })); - - const response = await telegramBot.sendMediaGroup( - accessToken, - mediaGroup as any[] - ); - if (i === 0) { - messageId = response[0].message_id; - } - } - } - // for private groups/channels message.id is undefined so the link generated by Postiz will be unusable "https://t.me/c/undefined/16" - // to avoid that, we use accessToken instead of message.id and we generate the link manually removing the -100 from the start. - if (messageId) { - ids.push({ - id: message.id, + // for private groups/channels message.id is undefined so the link generated by Postiz will be unusable "https://t.me/c/undefined/16" + // to avoid that, we use accessToken instead of message.id and we generate the link manually removing the -100 from the start. + if (messageId) { + return [ + { + id: firstPost.id, postId: String(messageId), releaseURL: `https://t.me/${ id !== 'undefined' ? id : `c/${accessToken.replace('-100', '')}` }/${messageId}`, status: 'completed', - }); - } + }, + ]; } - return ids; + return []; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [commentPost] = postDetails; + const replyToId = Number(lastCommentId || postId); + + const messageId = await this.sendMessage(accessToken, commentPost, replyToId); + + if (messageId) { + return [ + { + id: commentPost.id, + postId: String(messageId), + releaseURL: `https://t.me/${ + id !== 'undefined' ? id : `c/${accessToken.replace('-100', '')}` + }/${messageId}`, + status: 'completed', + }, + ]; + } + + return []; } // chunkMedia is used to split media into groups of "size". 10 is used here because telegram api allows a maximum of 10 media per group private chunkMedia(media: { type: string; media: string }[], size: number) { diff --git a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts index adbc8600..c871e1b6 100644 --- a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts @@ -11,6 +11,7 @@ import { createHash, randomBytes } from 'crypto'; import axios from 'axios'; import FormDataNew from 'form-data'; import mime from 'mime-types'; +import { Integration } from '@prisma/client'; export class VkProvider extends SocialAbstract implements SocialProvider { override maxConcurrentJob = 2; // VK has moderate API limits @@ -158,123 +159,153 @@ export class VkProvider extends SocialAbstract implements SocialProvider { }; } + private async uploadMedia( + userId: string, + accessToken: string, + post: PostDetails + ): Promise<{ id: string; type: string }[]> { + return await Promise.all( + (post?.media || []).map(async (media) => { + const all = await ( + await this.fetch( + media.path.indexOf('mp4') > -1 + ? `https://api.vk.com/method/video.save?access_token=${accessToken}&v=5.251` + : `https://api.vk.com/method/photos.getWallUploadServer?owner_id=${userId}&access_token=${accessToken}&v=5.251` + ) + ).json(); + + const { data } = await axios.get(media.path!, { + responseType: 'stream', + }); + + const slash = media.path.split('/').at(-1); + + const formData = new FormDataNew(); + formData.append('photo', data, { + filename: slash, + contentType: mime.lookup(slash!) || '', + }); + const value = ( + await axios.post(all.response.upload_url, formData, { + headers: { + ...formData.getHeaders(), + }, + }) + ).data; + + if (media.path.indexOf('mp4') > -1) { + return { + id: all.response.video_id, + type: 'video', + }; + } + + const formSend = new FormData(); + formSend.append('photo', value.photo); + formSend.append('server', value.server); + formSend.append('hash', value.hash); + + const { id } = ( + await ( + await fetch( + `https://api.vk.com/method/photos.saveWallPhoto?access_token=${accessToken}&v=5.251`, + { + method: 'POST', + body: formSend, + } + ) + ).json() + ).response[0]; + + return { + id, + type: 'photo', + }; + }) + ); + } + async post( userId: string, accessToken: string, postDetails: PostDetails[] ): Promise { - let replyTo = ''; - const values: PostResponse[] = []; + const [firstPost] = postDetails; - const uploading = await Promise.all( - postDetails.map(async (post) => { - return await Promise.all( - (post?.media || []).map(async (media) => { - const all = await ( - await this.fetch( - media.path.indexOf('mp4') > -1 - ? `https://api.vk.com/method/video.save?access_token=${accessToken}&v=5.251` - : `https://api.vk.com/method/photos.getWallUploadServer?owner_id=${userId}&access_token=${accessToken}&v=5.251` - ) - ).json(); + // Upload media for the first post + const mediaList = await this.uploadMedia(userId, accessToken, firstPost); - const { data } = await axios.get(media.path!, { - responseType: 'stream', - }); + const body = new FormData(); + body.append('message', firstPost.message); - const slash = media.path.split('/').at(-1); - - const formData = new FormDataNew(); - formData.append('photo', data, { - filename: slash, - contentType: mime.lookup(slash!) || '', - }); - const value = ( - await axios.post(all.response.upload_url, formData, { - headers: { - ...formData.getHeaders(), - }, - }) - ).data; - - if (media.path.indexOf('mp4') > -1) { - return { - id: all.response.video_id, - type: 'video', - }; - } - - const formSend = new FormData(); - formSend.append('photo', value.photo); - formSend.append('server', value.server); - formSend.append('hash', value.hash); - - const { id } = ( - await ( - await fetch( - `https://api.vk.com/method/photos.saveWallPhoto?access_token=${accessToken}&v=5.251`, - { - method: 'POST', - body: formSend, - } - ) - ).json() - ).response[0]; - - return { - id, - type: 'photo', - }; - }) - ); - }) - ); - - let i = 0; - for (const post of postDetails) { - const list = uploading?.[i] || []; - - const body = new FormData(); - body.append('message', post.message); - if (replyTo) { - body.append('post_id', replyTo); - } - - if (list.length) { - body.append( - 'attachments', - list.map((p) => `${p.type}${userId}_${p.id}`).join(',') - ); - } - - const { response, ...all } = await ( - await this.fetch( - `https://api.vk.com/method/${ - replyTo ? 'wall.createComment' : 'wall.post' - }?v=5.251&access_token=${accessToken}&client_id=${process.env.VK_ID}`, - { - method: 'POST', - body, - } - ) - ).json(); - - values.push({ - id: post.id, - postId: String(response?.post_id || response?.comment_id), - releaseURL: `https://vk.com/feed?w=wall${userId}_${ - response?.post_id || replyTo - }`, - status: 'completed', - }); - - if (!replyTo) { - replyTo = response.post_id; - } - - i++; + if (mediaList.length) { + body.append( + 'attachments', + mediaList.map((p) => `${p.type}${userId}_${p.id}`).join(',') + ); } - return values; + const { response } = await ( + await this.fetch( + `https://api.vk.com/method/wall.post?v=5.251&access_token=${accessToken}&client_id=${process.env.VK_ID}`, + { + method: 'POST', + body, + } + ) + ).json(); + + return [ + { + id: firstPost.id, + postId: String(response?.post_id), + releaseURL: `https://vk.com/feed?w=wall${userId}_${response?.post_id}`, + status: 'completed', + }, + ]; + } + + async comment( + userId: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [commentPost] = postDetails; + + // Upload media for the comment + const mediaList = await this.uploadMedia(userId, accessToken, commentPost); + + const body = new FormData(); + body.append('message', commentPost.message); + body.append('post_id', postId); + + if (mediaList.length) { + body.append( + 'attachments', + mediaList.map((p) => `${p.type}${userId}_${p.id}`).join(',') + ); + } + + const { response } = await ( + await this.fetch( + `https://api.vk.com/method/wall.createComment?v=5.251&access_token=${accessToken}&client_id=${process.env.VK_ID}`, + { + method: 'POST', + body, + } + ) + ).json(); + + return [ + { + id: commentPost.id, + postId: String(response?.comment_id), + releaseURL: `https://vk.com/feed?w=wall${userId}_${postId}`, + status: 'completed', + }, + ]; } }