fix: urgent providers fix

This commit is contained in:
Nevo David 2026-01-17 16:35:56 +07:00
parent 7eeb1cb044
commit e29812501e
4 changed files with 429 additions and 293 deletions

View File

@ -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<LemmySettingsDto>[],
integration: Integration
): Promise<PostResponse[]> {
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<LemmySettingsDto>[],
integration: Integration
): Promise<PostResponse[]> {
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<LemmySettingsDto>[],
integration: Integration
): Promise<PostResponse[]> {
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}`,

View File

@ -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<PostResponse[]> {
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<PostResponse[]> {
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',
},
];
}
}

View File

@ -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<number | null> {
let messageId: number | null = null;
const mediaFiles = message.media || [];
const text = striptags(message.message || '', ['u', 'strong', 'p'])
.replace(/<strong>/g, '<b>')
.replace(/<\/strong>/g, '</b>')
.replace(/<p>(.*?)<\/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<PostResponse[]> {
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(/<strong>/g, '<b>')
.replace(/<\/strong>/g, '</b>')
.replace(/<p>(.*?)<\/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<PostResponse[]> {
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) {

View File

@ -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<PostResponse[]> {
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<PostResponse[]> {
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',
},
];
}
}