Merge branch 'main' into add-authentik-sso

This commit is contained in:
DrummyFloyd 2025-04-30 14:15:40 +02:00 committed by GitHub
commit 2a87ba1959
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 3219 additions and 1976 deletions

View File

@ -78,6 +78,7 @@ OPENAI_API_KEY=""
NEXT_PUBLIC_DISCORD_SUPPORT=""
NEXT_PUBLIC_POLOTNO=""
NOT_SECURED=false
API_LIMIT=30 # The limit of the public API hour limit
# Payment settings
FEE_AMOUNT=0.05

View File

@ -3,8 +3,7 @@ name: Build
on:
push:
branches:
- main
pull_request:
jobs:
build:

View File

@ -33,7 +33,6 @@ import { SignatureController } from '@gitroom/backend/api/routes/signature.contr
import { AutopostController } from '@gitroom/backend/api/routes/autopost.controller';
import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service';
import { McpController } from '@gitroom/backend/api/routes/mcp.controller';
import { McpSettings } from '@gitroom/nestjs-libraries/mcp/mcp.settings';
const authenticatedController = [
UsersController,

View File

@ -27,7 +27,7 @@ export class CopilotController {
req?.body?.variables?.data?.metadata?.requestType ===
'TextareaCompletion'
? 'gpt-4o-mini'
: 'gpt-4o-2024-08-06',
: 'gpt-4.1',
}),
});

View File

@ -2,6 +2,7 @@ import {
Controller,
Get,
Header,
HttpException,
Param,
Post,
RawBodyRequest,
@ -52,27 +53,34 @@ export class StripeController {
);
// Maybe it comes from another stripe webhook
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (event?.data?.object?.metadata?.service !== 'gitroom' && event.type !== 'invoice.payment_succeeded') {
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
event?.data?.object?.metadata?.service !== 'gitroom' &&
event.type !== 'invoice.payment_succeeded'
) {
return { ok: true };
}
switch (event.type) {
case 'invoice.payment_succeeded':
return this._stripeService.paymentSucceeded(event);
case 'checkout.session.completed':
return this._stripeService.updateOrder(event);
case 'account.updated':
return this._stripeService.updateAccount(event);
case 'customer.subscription.created':
return this._stripeService.createSubscription(event);
case 'customer.subscription.updated':
return this._stripeService.updateSubscription(event);
case 'customer.subscription.deleted':
return this._stripeService.deleteSubscription(event);
default:
return { ok: true };
try {
switch (event.type) {
case 'invoice.payment_succeeded':
return this._stripeService.paymentSucceeded(event);
case 'checkout.session.completed':
return this._stripeService.updateOrder(event);
case 'account.updated':
return this._stripeService.updateAccount(event);
case 'customer.subscription.created':
return this._stripeService.createSubscription(event);
case 'customer.subscription.updated':
return this._stripeService.updateSubscription(event);
case 'customer.subscription.deleted':
return this._stripeService.deleteSubscription(event);
default:
return { ok: true };
}
} catch (e) {
throw new HttpException(e, 500);
}
}

View File

@ -25,7 +25,7 @@ import { McpModule } from '@gitroom/backend/mcp/mcp.module';
ThrottlerModule.forRoot([
{
ttl: 3600000,
limit: 30,
limit: process.env.API_LIMIT ? Number(process.env.API_LIMIT) : 30,
},
]),
],
@ -40,8 +40,15 @@ import { McpModule } from '@gitroom/backend/mcp/mcp.module';
useClass: PoliciesGuard,
},
],
get exports() {
return [...this.imports];
},
exports: [
BullMqModule,
DatabaseModule,
ApiModule,
PluginModule,
PublicApiModule,
AgentModule,
McpModule,
ThrottlerModule,
],
})
export class AppModule {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -22,6 +22,14 @@ export default async function Page({
};
}
if (provider === 'vk') {
searchParams = {
...searchParams,
state: searchParams.state || '',
code: searchParams.code + '&&&&' + searchParams.device_id
};
}
const data = await internalFetch(`/integrations/social/${provider}/connect`, {
method: 'POST',
body: JSON.stringify(searchParams),

View File

@ -13,7 +13,7 @@ export default async function AuthLayout({
children: ReactNode;
}) {
return (
<>
<div className="dark !bg-black">
<ReturnUrlComponent />
<div className="absolute left-0 top-0 z-[0] h-[100vh] w-[100vw] overflow-hidden bg-loginBg bg-contain bg-no-repeat bg-left-top" />
<div className="relative z-[1] px-3 lg:pr-[100px] xs:mt-[70px] flex justify-center lg:justify-end items-center h-[100vh] w-[100vw] overflow-hidden">
@ -75,6 +75,6 @@ export default async function AuthLayout({
<div className="absolute right-0 bg-gradient-to-l from-customColor9 h-[1px] -translate-y-[22px] w-full" />
</div>
</div>
</>
</div>
);
}

View File

@ -503,7 +503,7 @@ export const MainBillingComponent: FC<{
</div>
))}
</div>
<PurchaseCrypto />
{!subscription?.id && (<PurchaseCrypto />)}
{!!subscription?.id && (
<div className="flex justify-center mt-[20px] gap-[10px]">
<Button onClick={updatePayment}>Update Payment Method / Invoices History</Button>

View File

@ -59,6 +59,7 @@ import { DropFiles } from '@gitroom/frontend/components/layout/drop.files';
import { SelectCustomer } from '@gitroom/frontend/components/launches/select.customer';
import { TagsComponent } from './tags.component';
import { RepeatComponent } from '@gitroom/frontend/components/launches/repeat.component';
import { MergePost } from '@gitroom/frontend/components/launches/merge.post';
function countCharacters(text: string, type: string): number {
if (type !== 'x') {
@ -136,6 +137,27 @@ export const AddEditModal: FC<{
// hook to test if the top editor should be hidden
const showHide = useHideTopEditor();
// merge all posts and delete all the comments
const merge = useCallback(() => {
setValue(
value.reduce(
(all, current) => {
all[0].content = all[0].content + current.content + '\n';
all[0].image = [...all[0].image, ...(current.image || [])];
return all;
},
[
{
content: '',
id: value[0].id,
image: [] as { id: string; path: string }[],
},
]
)
);
}, [value]);
const [showError, setShowError] = useState(false);
// are we in edit mode?
@ -536,7 +558,14 @@ export const AddEditModal: FC<{
title: 'AI Content Assistant',
}}
className="!z-[499]"
instructions="You are an assistant that help the user to schedule their social media posts, everytime somebody write something, try to use a function call, if not prompt the user that the request is invalid and you are here to assists with social media posts"
instructions={`
You are an assistant that help the user to schedule their social media posts,
Here are the things you can do:
- Add a new comment / post to the list of posts
- Delete a comment / post from the list of posts
- Add content to the comment / post
- Activate or deactivate the comment / post
`}
/>
)}
<div
@ -651,16 +680,8 @@ export const AddEditModal: FC<{
<Editor
order={index}
height={value.length > 1 ? 150 : 250}
commands={
[
// ...commands
// .getCommands()
// .filter((f) => f.name === 'image'),
// newImage,
// postSelector(dateState),
]
}
value={p.content}
totalPosts={value.length}
preview="edit"
onPaste={pasteImages(index, p.image || [])}
// @ts-ignore
@ -731,6 +752,11 @@ export const AddEditModal: FC<{
</div>
</Fragment>
))}
{value.length > 1 && (
<div>
<MergePost merge={merge} />
</div>
)}
</>
) : null}
</div>

View File

@ -9,7 +9,7 @@ export const AddPostButton: FC<{ onClick: () => void; num: number }> = (
useCopilotAction({
name: 'addPost_' + num,
description: 'Add a post after the post number ' + (num + 1),
description: 'Add a post after the post number ' + num,
handler: () => {
onClick();
},

View File

@ -15,13 +15,19 @@ import { SignatureBox } from '@gitroom/frontend/components/signature';
export const Editor = forwardRef<
RefMDEditor,
MDEditorProps & { order: number; currentWatching: string; isGlobal: boolean }
MDEditorProps & {
order: number;
currentWatching: string;
isGlobal: boolean;
totalPosts: number;
}
>(
(
props: MDEditorProps & {
order: number;
currentWatching: string;
isGlobal: boolean;
totalPosts: number;
},
ref: React.ForwardedRef<RefMDEditor>
) => {
@ -32,12 +38,16 @@ export const Editor = forwardRef<
useCopilotReadable({
description: 'Content of the post number ' + (props.order + 1),
value: props.value,
value: JSON.stringify({
content: props.value,
order: props.order,
allowAddContent: props?.value?.length === 0,
}),
});
useCopilotAction({
name: 'editPost_' + props.order,
description: `Edit the content of post number ${props.order + 1}`,
description: `Edit the content of post number ${props.order}`,
parameters: [
{
name: 'content',
@ -93,7 +103,8 @@ export const Editor = forwardRef<
disableBranding={true}
ref={newRef}
className={clsx(
'!min-h-40 !max-h-80 p-2 overflow-x-hidden scrollbar scrollbar-thumb-[#612AD5] bg-customColor2 outline-none'
'!min-h-40 p-2 overflow-x-hidden scrollbar scrollbar-thumb-[#612AD5] bg-customColor2 outline-none',
props.totalPosts > 1 && '!max-h-80'
)}
value={props.value}
onChange={(e) => {

View File

@ -0,0 +1,19 @@
import { Button } from '@gitroom/react/form/button';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { FC, useCallback } from 'react';
export const MergePost: FC<{merge: () => void}> = (props) => {
const { merge } = props;
const notReversible = useCallback(async () => {
if (await deleteDialog('Are you sure you want to merge all comments into one post? This action is not reversible.', 'Yes')) {
merge();
}
}, [merge]);
return (
<Button className="!h-[30px] !text-sm !bg-red-800" onClick={notReversible}>
Merge comments into one post
</Button>
);
};

View File

@ -43,6 +43,7 @@ import { DropFiles } from '@gitroom/frontend/components/layout/drop.files';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { InternalChannels } from '@gitroom/frontend/components/launches/internal.channels';
import { MergePost } from '@gitroom/frontend/components/launches/merge.post';
// Simple component to change back to settings on after changing tab
export const SetTab: FC<{ changeTab: () => void }> = (props) => {
@ -176,6 +177,26 @@ export const withProvider = function <T extends object>(
[InPlaceValue]
);
const merge = useCallback(() => {
setInPlaceValue(
InPlaceValue.reduce(
(all, current) => {
all[0].content = all[0].content + current.content + '\n';
all[0].image = [...all[0].image, ...(current.image || [])];
return all;
},
[
{
content: '',
id: InPlaceValue[0].id,
image: [] as { id: string; path: string }[],
},
]
)
);
}, [InPlaceValue]);
const changeImage = useCallback(
(index: number) =>
(newValue: {
@ -464,6 +485,7 @@ export const withProvider = function <T extends object>(
order={index}
height={InPlaceValue.length > 1 ? 200 : 250}
value={val.content}
totalPosts={InPlaceValue.length}
commands={[
// ...commands
// .getCommands()
@ -545,6 +567,11 @@ export const withProvider = function <T extends object>(
</div>
</Fragment>
))}
{InPlaceValue.length > 1 && (
<div>
<MergePost merge={merge} />
</div>
)}
</div>
</EditorWrapper>,
document.querySelector('#renderEditor')!

View File

@ -21,6 +21,7 @@ import LemmyProvider from '@gitroom/frontend/components/launches/providers/lemmy
import WarpcastProvider from '@gitroom/frontend/components/launches/providers/warpcast/warpcast.provider';
import TelegramProvider from '@gitroom/frontend/components/launches/providers/telegram/telegram.provider';
import NostrProvider from '@gitroom/frontend/components/launches/providers/nostr/nostr.provider';
import VkProvider from '@gitroom/frontend/components/launches/providers/vk/vk.provider';
export const Providers = [
{identifier: 'devto', component: DevtoProvider},
@ -46,6 +47,7 @@ export const Providers = [
{identifier: 'wrapcast', component: WarpcastProvider},
{identifier: 'telegram', component: TelegramProvider},
{identifier: 'nostr', component: NostrProvider},
{identifier: 'vk', component: VkProvider},
];

View File

@ -0,0 +1,11 @@
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
export default withProvider(
null,
undefined,
undefined,
async (posts) => {
return true;
},
2048
);

View File

@ -21,13 +21,13 @@ const toolNode = new ToolNode(tools);
const model = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY || 'sk-proj-',
model: 'gpt-4o-2024-08-06',
model: 'gpt-4.1',
temperature: 0.7,
});
const dalle = new DallEAPIWrapper({
apiKey: process.env.OPENAI_API_KEY || 'sk-proj-',
model: 'dall-e-3',
model: 'gpt-image-1',
});
interface WorkflowChannelsState {

View File

@ -33,6 +33,7 @@ export class BullMqServer extends Server implements CustomTransportStrategy {
});
},
{
concurrency: 10,
connection: ioRedis,
removeOnComplete: {
count: 0,

View File

@ -33,13 +33,13 @@ interface WorkflowChannelsState {
const model = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY || 'sk-proj-',
model: 'gpt-4o-2024-08-06',
model: 'gpt-4.1',
temperature: 0.7,
});
const dalle = new DallEAPIWrapper({
apiKey: process.env.OPENAI_API_KEY || 'sk-proj-',
model: 'dall-e-3',
model: 'gpt-image-1',
});
const generateContent = z.object({

View File

@ -26,6 +26,7 @@ import { InstagramStandaloneProvider } from '@gitroom/nestjs-libraries/integrati
import { FarcasterProvider } from '@gitroom/nestjs-libraries/integrations/social/farcaster.provider';
import { TelegramProvider } from '@gitroom/nestjs-libraries/integrations/social/telegram.provider';
import { NostrProvider } from '@gitroom/nestjs-libraries/integrations/social/nostr.provider';
import { VkProvider } from '@gitroom/nestjs-libraries/integrations/social/vk.provider';
export const socialIntegrationList: SocialProvider[] = [
new XProvider(),
@ -48,6 +49,7 @@ export const socialIntegrationList: SocialProvider[] = [
new FarcasterProvider(),
new TelegramProvider(),
new NostrProvider(),
new VkProvider(),
// new MastodonCustomProvider(),
];

View File

@ -413,7 +413,7 @@ export class LinkedinPageProvider
if (totalLikes >= +fields.likesAmount) {
await timer(2000);
await this.fetch(`https://api.linkedin.com/v2/posts`, {
await this.fetch(`https://api.linkedin.com/rest/posts`, {
body: JSON.stringify({
author: `urn:li:organization:${integration.internalId}`,
commentary: '',
@ -433,7 +433,7 @@ export class LinkedinPageProvider
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202501',
'LinkedIn-Version': '202504',
Authorization: `Bearer ${integration.token}`,
},
});

View File

@ -472,7 +472,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
isPersonal = true
) {
try {
await this.fetch(`https://api.linkedin.com/v2/posts`, {
await this.fetch(`https://api.linkedin.com/rest/posts`, {
body: JSON.stringify({
author:
(isPersonal ? 'urn:li:person:' : `urn:li:organization:`) +
@ -494,7 +494,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202501',
'LinkedIn-Version': '202504',
Authorization: `Bearer ${integration.token}`,
},
});

View File

@ -145,178 +145,240 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
};
}
private async createSingleMediaContent(
userId: string,
accessToken: string,
media: { url: string },
message: string,
isCarouselItem = false,
replyToId?: string
): Promise<string> {
const mediaType = media.url.indexOf('.mp4') > -1 ? 'video_url' : 'image_url';
const mediaParams = new URLSearchParams({
...(mediaType === 'video_url' ? { video_url: media.url } : {}),
...(mediaType === 'image_url' ? { image_url: media.url } : {}),
...(isCarouselItem ? { is_carousel_item: 'true' } : {}),
...(replyToId ? { reply_to_id: replyToId } : {}),
media_type: mediaType === 'video_url' ? 'VIDEO' : 'IMAGE',
text: message,
access_token: accessToken,
});
console.log(mediaParams);
const { id: mediaId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${userId}/threads?${mediaParams.toString()}`,
{
method: 'POST',
}
)
).json();
return mediaId;
}
private async createCarouselContent(
userId: string,
accessToken: string,
media: { url: string }[],
message: string,
replyToId?: string
): Promise<string> {
// Create each media item
const mediaIds = [];
for (const mediaItem of media) {
const mediaId = await this.createSingleMediaContent(
userId,
accessToken,
mediaItem,
message,
true
);
mediaIds.push(mediaId);
}
// Wait for all media to be loaded
await Promise.all(
mediaIds.map((id: string) => this.checkLoaded(id, accessToken))
);
// Create carousel container
const params = new URLSearchParams({
text: message,
media_type: 'CAROUSEL',
children: mediaIds.join(','),
...(replyToId ? { reply_to_id: replyToId } : {}),
access_token: accessToken,
});
const { id: containerId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${userId}/threads?${params.toString()}`,
{
method: 'POST',
}
)
).json();
return containerId;
}
private async createTextContent(
userId: string,
accessToken: string,
message: string,
replyToId?: string
): Promise<string> {
const form = new FormData();
form.append('media_type', 'TEXT');
form.append('text', message);
form.append('access_token', accessToken);
if (replyToId) {
form.append('reply_to_id', replyToId);
}
const { id: contentId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${userId}/threads`,
{
method: 'POST',
body: form,
}
)
).json();
return contentId;
}
private async publishThread(
userId: string,
accessToken: string,
creationId: string
): Promise<{ threadId: string; permalink: string }> {
await this.checkLoaded(creationId, accessToken);
const { id: threadId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${userId}/threads_publish?creation_id=${creationId}&access_token=${accessToken}`,
{
method: 'POST',
}
)
).json();
const { permalink } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${threadId}?fields=id,permalink&access_token=${accessToken}`
)
).json();
return { threadId, permalink };
}
private async createThreadContent(
userId: string,
accessToken: string,
postDetails: PostDetails,
replyToId?: string
): Promise<string> {
// Handle content creation based on media type
if (!postDetails.media || postDetails.media.length === 0) {
// Text-only content
return await this.createTextContent(
userId,
accessToken,
postDetails.message,
replyToId
);
} else if (postDetails.media.length === 1) {
// Single media content
return await this.createSingleMediaContent(
userId,
accessToken,
postDetails.media[0],
postDetails.message,
false,
replyToId
);
} else {
// Carousel content
return await this.createCarouselContent(
userId,
accessToken,
postDetails.media,
postDetails.message,
replyToId
);
}
}
async post(
id: string,
userId: string,
accessToken: string,
postDetails: PostDetails[]
): Promise<PostResponse[]> {
const [firstPost, ...theRest] = postDetails;
if (!postDetails.length) {
return [];
}
let globalThread = '';
let link = '';
if (firstPost?.media?.length! <= 1) {
const type = !firstPost?.media?.[0]?.url
? undefined
: firstPost?.media![0].url.indexOf('.mp4') > -1
? 'video_url'
: 'image_url';
const media = new URLSearchParams({
...(type === 'video_url'
? { video_url: firstPost?.media![0].url }
: {}),
...(type === 'image_url'
? { image_url: firstPost?.media![0].url }
: {}),
media_type:
type === 'video_url'
? 'VIDEO'
: type === 'image_url'
? 'IMAGE'
: 'TEXT',
text: firstPost?.message,
access_token: accessToken,
});
const { id: containerId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${id}/threads?${media.toString()}`,
{
method: 'POST',
}
)
).json();
await this.checkLoaded(containerId, accessToken);
const { id: threadId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${id}/threads_publish?creation_id=${containerId}&access_token=${accessToken}`,
{
method: 'POST',
}
)
).json();
const { permalink, ...all } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${threadId}?fields=id,permalink&access_token=${accessToken}`
)
).json();
globalThread = threadId;
link = permalink;
} else {
const medias = [];
for (const mediaLoad of firstPost.media!) {
const type =
mediaLoad.url.indexOf('.mp4') > -1 ? 'video_url' : 'image_url';
const media = new URLSearchParams({
...(type === 'video_url' ? { video_url: mediaLoad.url } : {}),
...(type === 'image_url' ? { image_url: mediaLoad.url } : {}),
is_carousel_item: 'true',
media_type:
type === 'video_url'
? 'VIDEO'
: type === 'image_url'
? 'IMAGE'
: 'TEXT',
text: firstPost?.message,
access_token: accessToken,
});
const { id: mediaId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${id}/threads?${media.toString()}`,
{
method: 'POST',
}
)
).json();
medias.push(mediaId);
}
await Promise.all(
medias.map((p: string) => this.checkLoaded(p, accessToken))
const [firstPost, ...replies] = postDetails;
// Create the initial thread
const initialContentId = await this.createThreadContent(
userId,
accessToken,
firstPost
);
// Publish the thread
const { threadId, permalink } = await this.publishThread(
userId,
accessToken,
initialContentId
);
// Track the responses
const responses: PostResponse[] = [{
id: firstPost.id,
postId: threadId,
status: 'success',
releaseURL: permalink,
}];
// Handle replies if any
let lastReplyId = threadId;
for (const reply of replies) {
// Create reply content
const replyContentId = await this.createThreadContent(
userId,
accessToken,
reply,
lastReplyId
);
const { id: containerId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${id}/threads?text=${
firstPost?.message
}&media_type=CAROUSEL&children=${medias.join(
','
)}&access_token=${accessToken}`,
{
method: 'POST',
}
)
).json();
await this.checkLoaded(containerId, accessToken);
const { id: threadId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${id}/threads_publish?creation_id=${containerId}&access_token=${accessToken}`,
{
method: 'POST',
}
)
).json();
const { permalink } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${threadId}?fields=id,permalink&access_token=${accessToken}`
)
).json();
globalThread = threadId;
link = permalink;
}
let lastId = globalThread;
for (const post of theRest) {
const form = new FormData();
form.append('media_type', 'TEXT');
form.append('text', post.message);
form.append('reply_to_id', lastId);
form.append('access_token', accessToken);
const { id: replyId } = await (
await this.fetch('https://graph.threads.net/v1.0/me/threads', {
method: 'POST',
body: form,
})
).json();
const { id: threadMediaId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${id}/threads_publish?creation_id=${replyId}&access_token=${accessToken}`,
{
method: 'POST',
}
)
).json();
lastId = threadMediaId;
}
return [
{
id: firstPost.id,
postId: String(globalThread),
// Publish the reply
const { threadId: replyThreadId } = await this.publishThread(
userId,
accessToken,
replyContentId
);
// Update the last reply ID for chaining
lastReplyId = replyThreadId;
// Add to responses
responses.push({
id: reply.id,
postId: threadId, // Main thread ID
status: 'success',
releaseURL: link,
},
...theRest.map((p) => ({
id: p.id,
postId: String(globalThread),
status: 'success',
releaseURL: link,
})),
];
releaseURL: permalink, // Main thread URL
});
}
return responses;
}
async analytics(

View File

@ -0,0 +1,274 @@
import {
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { createHash, randomBytes } from 'crypto';
import axios from 'axios';
import FormDataNew from 'form-data';
import mime from 'mime-types';
export class VkProvider extends SocialAbstract implements SocialProvider {
identifier = 'vk';
name = 'VK';
isBetweenSteps = false;
scopes = [
'vkid.personal_info',
'email',
'wall',
'status',
'docs',
'photos',
'video',
];
async refreshToken(refresh: string): Promise<AuthTokenDetails> {
const [oldRefreshToken, device_id] = refresh.split('&&&&');
const formData = new FormData();
formData.append('grant_type', 'refresh_token');
formData.append('refresh_token', oldRefreshToken);
formData.append('client_id', process.env.VK_ID!);
formData.append('device_id', device_id);
formData.append('state', makeId(32));
formData.append('scope', this.scopes.join(' '));
const { access_token, refresh_token, expires_in } = await (
await this.fetch('https://id.vk.com/oauth2/auth', {
method: 'POST',
body: formData,
})
).json();
const newFormData = new FormData();
newFormData.append('client_id', process.env.VK_ID!);
newFormData.append('access_token', access_token);
const {
user: { user_id, first_name, last_name, avatar },
} = await (
await this.fetch('https://id.vk.com/oauth2/user_info', {
method: 'POST',
body: newFormData,
})
).json();
return {
id: user_id,
name: first_name + ' ' + last_name,
accessToken: access_token,
refreshToken: refresh_token + '&&&&' + device_id,
expiresIn: dayjs().add(expires_in, 'seconds').unix() - dayjs().unix(),
picture: avatar,
username: first_name.toLowerCase(),
};
}
async generateAuthUrl() {
const state = makeId(32);
const codeVerifier = randomBytes(64).toString('base64url');
const challenge = Buffer.from(
createHash('sha256').update(codeVerifier).digest()
)
.toString('base64')
.replace(/=*$/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
return {
url:
'https://id.vk.com/authorize' +
`?response_type=code` +
`&client_id=${process.env.VK_ID}` +
`&code_challenge_method=S256` +
`&code_challenge=${challenge}` +
`&redirect_uri=${encodeURIComponent(
`${
process?.env.FRONTEND_URL?.indexOf('https') == -1
? `https://redirectmeto.com/${process?.env.FRONTEND_URL}`
: `${process?.env.FRONTEND_URL}`
}/integrations/social/vk`
)}` +
`&state=${state}` +
`&scope=${encodeURIComponent(this.scopes.join(' '))}`,
codeVerifier,
state,
};
}
async authenticate(params: {
code: string;
codeVerifier: string;
refresh?: string;
}) {
const [code, device_id] = params.code.split('&&&&');
const formData = new FormData();
formData.append('client_id', process.env.VK_ID!);
formData.append('grant_type', 'authorization_code');
formData.append('code_verifier', params.codeVerifier);
formData.append('device_id', device_id);
formData.append('code', code);
formData.append(
'redirect_uri',
`${
process?.env.FRONTEND_URL?.indexOf('https') == -1
? `https://redirectmeto.com/${process?.env.FRONTEND_URL}`
: `${process?.env.FRONTEND_URL}`
}/integrations/social/vk`
);
const { access_token, scope, refresh_token, expires_in } = await (
await this.fetch('https://id.vk.com/oauth2/auth', {
method: 'POST',
body: formData,
})
).json();
const newFormData = new FormData();
newFormData.append('client_id', process.env.VK_ID!);
newFormData.append('access_token', access_token);
const {
user: { user_id, first_name, last_name, avatar },
} = await (
await this.fetch('https://id.vk.com/oauth2/user_info', {
method: 'POST',
body: newFormData,
})
).json();
return {
id: user_id,
name: first_name + ' ' + last_name,
accessToken: access_token,
refreshToken: refresh_token + '&&&&' + device_id,
expiresIn: dayjs().add(expires_in, 'seconds').unix() - dayjs().unix(),
picture: avatar,
username: first_name.toLowerCase(),
};
}
async post(
userId: string,
accessToken: string,
postDetails: PostDetails[]
): Promise<PostResponse[]> {
let replyTo = '';
const values: PostResponse[] = [];
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.url.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.url!, {
responseType: 'stream',
});
const slash = media.url.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.url.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++;
}
return values;
}
}

View File

@ -19,7 +19,7 @@ export class OpenaiService {
await openai.images.generate({
prompt,
response_format: isUrl ? 'url' : 'b64_json',
model: 'dall-e-3',
model: 'gpt-image-1',
})
).data[0];
@ -30,7 +30,7 @@ export class OpenaiService {
return (
(
await openai.beta.chat.completions.parse({
model: 'gpt-4o-2024-08-06',
model: 'gpt-4.1',
messages: [
{
role: 'system',
@ -64,7 +64,7 @@ export class OpenaiService {
],
n: 5,
temperature: 1,
model: 'gpt-4o',
model: 'gpt-4.1',
}),
openai.chat.completions.create({
messages: [
@ -80,7 +80,7 @@ export class OpenaiService {
],
n: 5,
temperature: 1,
model: 'gpt-4o',
model: 'gpt-4.1',
}),
])
).flatMap((p) => p.choices);
@ -118,7 +118,7 @@ export class OpenaiService {
content,
},
],
model: 'gpt-4o',
model: 'gpt-4.1',
});
const { content: articleContent } = websiteContent.choices[0].message;

4286
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -38,10 +38,10 @@
"@aws-sdk/client-s3": "^3.787.0",
"@aws-sdk/s3-request-presigner": "^3.787.0",
"@casl/ability": "^6.5.0",
"@copilotkit/react-core": "^1.8.5",
"@copilotkit/react-textarea": "^1.8.5",
"@copilotkit/react-ui": "^1.8.5",
"@copilotkit/runtime": "^1.8.5",
"@copilotkit/react-core": "^1.8.9",
"@copilotkit/react-textarea": "^1.8.9",
"@copilotkit/react-ui": "^1.8.9",
"@copilotkit/runtime": "^1.8.9",
"@hookform/resolvers": "^3.3.4",
"@langchain/community": "^0.3.40",
"@langchain/core": "^0.3.44",