+ );
+ }
+
+ async agent() {
+ const tools = await this.loadTools();
+ return new Agent({
+ name: 'postiz',
+ description: 'Agent that helps manage and schedule social media posts for users',
+ instructions: ({ runtimeContext }) => {
+ const ui: string = runtimeContext.get('ui' as never);
+ return `
+ Global information:
+ - Date (UTC): ${dayjs().format('YYYY-MM-DD HH:mm:ss')}
+
+ You are an agent that helps manage and schedule social media posts for users, you can:
+ - Schedule posts into the future, or now, adding texts, images and videos
+ - Generate pictures for posts
+ - Generate videos for posts
+ - Generate text for posts
+ - Show global analytics about socials
+ - List integrations (channels)
+
+ - We schedule posts to different integration like facebook, instagram, etc. but to the user we don't say integrations we say channels as integration is the technical name
+ - When scheduling a post, you must follow the social media rules and best practices.
+ - When scheduling a post, you can pass an array for list of posts for a social media platform, But it has different behavior depending on the platform.
+ - For platforms like Threads, Bluesky and X (Twitter), each post in the array will be a separate post in the thread.
+ - For platforms like LinkedIn and Facebook, second part of the array will be added as "comments" to the first post.
+ - If the social media platform has the concept of "threads", we need to ask the user if they want to create a thread or one long post.
+ - For X, if you don't have Premium, don't suggest a long post because it won't work.
+ - Platform format will also be passed can be "normal", "markdown", "html", make sure you use the correct format for each platform.
+
+ - Sometimes 'integrationSchema' will return rules, make sure you follow them (these rules are set in stone, even if the user asks to ignore them)
+ - Each socials media platform has different settings and rules, you can get them by using the integrationSchema tool.
+ - Always make sure you use this tool before you schedule any post.
+ - In every message I will send you the list of needed social medias (id and platform), if you already have the information use it, if not, use the integrationSchema tool to get it.
+ - Make sure you always take the last information I give you about the socials, it might have changed.
+ - Before scheduling a post, always make sure you ask the user confirmation by providing all the details of the post (text, images, videos, date, time, social media platform, account).
+ - Between tools, we will reference things like: [output:name] and [input:name] to set the information right.
+ - When outputting a date for the user, make sure it's human readable with time
+ - The content of the post, HTML, Each line must be wrapped in here is the possible tags: h1, h2, h3, u, strong, li, ul, p (you can\'t have u and strong together), don't use a "code" box
+ ${renderArray(
+ [
+ 'If the user confirm, ask if they would like to get a modal with populated content without scheduling the post yet or if they want to schedule it right away.',
+ ],
+ !!ui
+ )}
+`;
+ },
+ model: openai('gpt-4.1'),
+ tools,
+ memory: new Memory({
+ storage: pStore,
+ options: {
+ threads: {
+ generateTitle: true,
+ },
+ workingMemory: {
+ enabled: true,
+ schema: AgentState,
+ },
+ },
+ }),
+ });
+ }
+}
diff --git a/libraries/nestjs-libraries/src/chat/mastra.service.ts b/libraries/nestjs-libraries/src/chat/mastra.service.ts
new file mode 100644
index 00000000..27f1b0a3
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/mastra.service.ts
@@ -0,0 +1,26 @@
+import { Mastra } from '@mastra/core/mastra';
+import { ConsoleLogger } from '@mastra/core/logger';
+import { pStore } from '@gitroom/nestjs-libraries/chat/mastra.store';
+import { Injectable } from '@nestjs/common';
+import { LoadToolsService } from '@gitroom/nestjs-libraries/chat/load.tools.service';
+
+@Injectable()
+export class MastraService {
+ static mastra: Mastra;
+ constructor(private _loadToolsService: LoadToolsService) {}
+ async mastra() {
+ MastraService.mastra =
+ MastraService.mastra ||
+ new Mastra({
+ storage: pStore,
+ agents: {
+ postiz: await this._loadToolsService.agent(),
+ },
+ logger: new ConsoleLogger({
+ level: 'info',
+ }),
+ });
+
+ return MastraService.mastra;
+ }
+}
diff --git a/libraries/nestjs-libraries/src/chat/mastra.store.ts b/libraries/nestjs-libraries/src/chat/mastra.store.ts
new file mode 100644
index 00000000..d5601601
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/mastra.store.ts
@@ -0,0 +1,5 @@
+import { PostgresStore, PgVector } from '@mastra/pg';
+
+export const pStore = new PostgresStore({
+ connectionString: process.env.DATABASE_URL,
+});
diff --git a/libraries/nestjs-libraries/src/chat/rules.description.decorator.ts b/libraries/nestjs-libraries/src/chat/rules.description.decorator.ts
new file mode 100644
index 00000000..a817d629
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/rules.description.decorator.ts
@@ -0,0 +1,12 @@
+import 'reflect-metadata';
+
+export function Rules(description: string) {
+ return function (target: any) {
+ // Define metadata on the class prototype (so it can be retrieved from the class)
+ Reflect.defineMetadata(
+ 'custom:rules:description',
+ description,
+ target
+ );
+ };
+}
diff --git a/libraries/nestjs-libraries/src/chat/start.mcp.ts b/libraries/nestjs-libraries/src/chat/start.mcp.ts
new file mode 100644
index 00000000..d2089a90
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/start.mcp.ts
@@ -0,0 +1,66 @@
+import { INestApplication } from '@nestjs/common';
+import { Request, Response } from 'express';
+import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service';
+import { MCPServer } from '@mastra/mcp';
+import { randomUUID } from 'crypto';
+import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
+import { runWithContext } from './async.storage';
+export const startMcp = async (app: INestApplication) => {
+ const mastraService = app.get(MastraService, { strict: false });
+ const organizationService = app.get(OrganizationService, { strict: false });
+
+ const mastra = await mastraService.mastra();
+ const agent = mastra.getAgent('postiz');
+ const tools = await agent.getTools();
+
+ const server = new MCPServer({
+ name: 'Postiz MCP',
+ version: '1.0.0',
+ tools,
+ agents: { postiz: agent },
+ });
+
+ app.use(
+ '/mcp/:id',
+ async (req: Request, res: Response) => {
+ // @ts-ignore
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', '*');
+ res.setHeader('Access-Control-Allow-Headers', '*');
+ res.setHeader('Access-Control-Expose-Headers', '*');
+
+ if (req.method === 'OPTIONS') {
+ res.sendStatus(200);
+ return;
+ }
+
+ // @ts-ignore
+ req.auth = await organizationService.getOrgByApiKey(req.params.id);
+ // @ts-ignore
+ if (!req.auth) {
+ res.status(400).send('Invalid API Key');
+ return ;
+ }
+
+ const url = new URL(
+ `/mcp/${req.params.id}`,
+ process.env.NEXT_PUBLIC_BACKEND_URL
+ );
+
+ // @ts-ignore
+ await runWithContext({ requestId: req.params.id, auth: req.auth }, async () => {
+ await server.startHTTP({
+ url,
+ httpPath: url.pathname,
+ options: {
+ sessionIdGenerator: () => {
+ return randomUUID();
+ },
+ },
+ req,
+ res,
+ });
+ });
+ }
+ );
+};
diff --git a/libraries/nestjs-libraries/src/chat/tools/generate.image.tool.ts b/libraries/nestjs-libraries/src/chat/tools/generate.image.tool.ts
new file mode 100644
index 00000000..f475f189
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/tools/generate.image.tool.ts
@@ -0,0 +1,48 @@
+import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface';
+import { createTool } from '@mastra/core/tools';
+import { z } from 'zod';
+import { Injectable } from '@nestjs/common';
+import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
+import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
+import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context';
+
+@Injectable()
+export class GenerateImageTool implements AgentToolInterface {
+ private storage = UploadFactory.createStorage();
+
+ constructor(private _mediaService: MediaService) {}
+ name = 'generateImageTool';
+
+ run() {
+ return createTool({
+ id: 'generateImageTool',
+ description: `Generate image to use in a post,
+ in case the user specified a platform that requires attachment and attachment was not provided,
+ ask if they want to generate a picture of a video.
+ `,
+ inputSchema: z.object({
+ prompt: z.string(),
+ }),
+ outputSchema: z.object({
+ id: z.string(),
+ path: z.string(),
+ }),
+ execute: async (args, options) => {
+ const { context, runtimeContext } = args;
+ checkAuth(args, options);
+ // @ts-ignore
+ const org = JSON.parse(runtimeContext.get('organization') as string);
+ const image = await this._mediaService.generateImage(
+ context.prompt,
+ org
+ );
+
+ const file = await this.storage.uploadSimple(
+ 'data:image/png;base64,' + image
+ );
+
+ return this._mediaService.saveFile(org.id, file.split('/').pop(), file);
+ },
+ });
+ }
+}
diff --git a/libraries/nestjs-libraries/src/chat/tools/generate.video.options.tool.ts b/libraries/nestjs-libraries/src/chat/tools/generate.video.options.tool.ts
new file mode 100644
index 00000000..7879c97c
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/tools/generate.video.options.tool.ts
@@ -0,0 +1,66 @@
+import { AgentToolInterface, ToolReturn } from '@gitroom/nestjs-libraries/chat/agent.tool.interface';
+import { createTool } from '@mastra/core/tools';
+import { Injectable } from '@nestjs/common';
+import { validationMetadatasToSchemas } from 'class-validator-jsonschema';
+import { VideoManager } from '@gitroom/nestjs-libraries/videos/video.manager';
+import z from 'zod';
+import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context';
+
+@Injectable()
+export class GenerateVideoOptionsTool implements AgentToolInterface {
+ constructor(private _videoManagerService: VideoManager) {}
+ name = 'generateVideoOptions';
+
+ run() {
+ return createTool({
+ id: 'generateVideoOptions',
+ description: `All the options to generate videos, some tools might require another call to generateVideoFunction`,
+ outputSchema: z.object({
+ video: z.array(
+ z.object({
+ type: z.string(),
+ output: z.string(),
+ tools: z.array(
+ z.object({
+ functionName: z.string(),
+ output: z.string(),
+ })
+ ),
+ customParams: z.any(),
+ })
+ ),
+ }),
+ execute: async (args, options) => {
+ const { context, runtimeContext } = args;
+ checkAuth(args, options);
+ const videos = this._videoManagerService.getAllVideos();
+ console.log(
+ JSON.stringify(
+ {
+ video: videos.map((p) => {
+ return {
+ type: p.identifier,
+ output: 'vertical|horizontal',
+ tools: p.tools,
+ customParams: validationMetadatasToSchemas()[p.dto.name],
+ };
+ }),
+ },
+ null,
+ 2
+ )
+ );
+ return {
+ video: videos.map((p) => {
+ return {
+ type: p.identifier,
+ output: 'vertical|horizontal',
+ tools: p.tools,
+ customParams: validationMetadatasToSchemas()[p.dto.name],
+ };
+ }),
+ };
+ },
+ });
+ }
+}
diff --git a/libraries/nestjs-libraries/src/chat/tools/generate.video.tool.ts b/libraries/nestjs-libraries/src/chat/tools/generate.video.tool.ts
new file mode 100644
index 00000000..35740a1e
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/tools/generate.video.tool.ts
@@ -0,0 +1,75 @@
+import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface';
+import { createTool } from '@mastra/core/tools';
+import { z } from 'zod';
+import { Injectable } from '@nestjs/common';
+import {
+ IntegrationManager,
+ socialIntegrationList,
+} from '@gitroom/nestjs-libraries/integrations/integration.manager';
+import { validationMetadatasToSchemas } from 'class-validator-jsonschema';
+import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
+import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
+import { timer } from '@gitroom/helpers/utils/timer';
+import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
+import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
+import { VideoManager } from '@gitroom/nestjs-libraries/videos/video.manager';
+import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context';
+
+@Injectable()
+export class GenerateVideoTool implements AgentToolInterface {
+ constructor(
+ private _mediaService: MediaService,
+ private _videoManager: VideoManager
+ ) {}
+ name = 'generateVideoTool';
+
+ run() {
+ return createTool({
+ id: 'generateVideoTool',
+ description: `Generate video to use in a post,
+ in case the user specified a platform that requires attachment and attachment was not provided,
+ ask if they want to generate a picture of a video.
+ In many cases 'videoFunctionTool' will need to be called first, to get things like voice id
+ Here are the type of video that can be generated:
+ ${this._videoManager
+ .getAllVideos()
+ .map((p) => "-" + p.title)
+ .join('\n')}
+ `,
+ inputSchema: z.object({
+ identifier: z.string(),
+ output: z.enum(['vertical', 'horizontal']),
+ customParams: z.array(
+ z.object({
+ key: z.string().describe('Name of the settings key to pass'),
+ value: z.any().describe('Value of the key'),
+ })
+ ),
+ }),
+ outputSchema: z.object({
+ url: z.string(),
+ }),
+ execute: async (args, options) => {
+ const { context, runtimeContext } = args;
+ checkAuth(args, options);
+ // @ts-ignore
+ const org = JSON.parse(runtimeContext.get('organization') as string);
+ const value = await this._mediaService.generateVideo(org, {
+ type: context.identifier,
+ output: context.output,
+ customParams: context.customParams.reduce(
+ (all, current) => ({
+ ...all,
+ [current.key]: current.value,
+ }),
+ {}
+ ),
+ });
+
+ return {
+ url: value.path,
+ };
+ },
+ });
+ }
+}
diff --git a/libraries/nestjs-libraries/src/chat/tools/integration.list.tool.ts b/libraries/nestjs-libraries/src/chat/tools/integration.list.tool.ts
new file mode 100644
index 00000000..98d54ce2
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/tools/integration.list.tool.ts
@@ -0,0 +1,57 @@
+import {
+ AgentToolInterface,
+ ToolReturn,
+} from '@gitroom/nestjs-libraries/chat/agent.tool.interface';
+import { createTool } from '@mastra/core/tools';
+import { Injectable } from '@nestjs/common';
+import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
+import z from 'zod';
+import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context';
+import { getAuth } from '@gitroom/nestjs-libraries/chat/async.storage';
+
+@Injectable()
+export class IntegrationListTool implements AgentToolInterface {
+ constructor(private _integrationService: IntegrationService) {}
+ name = 'integrationList';
+
+ run() {
+ return createTool({
+ id: 'integrationList',
+ description: `This tool list available integrations to schedule posts to`,
+ outputSchema: z.object({
+ output: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ picture: z.string(),
+ platform: z.string(),
+ })
+ ),
+ }),
+ execute: async (args, options) => {
+ console.log(getAuth());
+ console.log(options);
+ const { context, runtimeContext } = args;
+ checkAuth(args, options);
+ const organizationId = JSON.parse(
+ // @ts-ignore
+ runtimeContext.get('organization') as string
+ ).id;
+
+ return {
+ output: (
+ await this._integrationService.getIntegrationsList(organizationId)
+ ).map((p) => ({
+ name: p.name,
+ id: p.id,
+ disabled: p.disabled,
+ picture: p.picture || '/no-picture.jpg',
+ platform: p.providerIdentifier,
+ display: p.profile,
+ type: p.type,
+ })),
+ };
+ },
+ });
+ }
+}
diff --git a/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts
new file mode 100644
index 00000000..d10608f9
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts
@@ -0,0 +1,226 @@
+import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface';
+import { createTool } from '@mastra/core/tools';
+import { z } from 'zod';
+import { Injectable } from '@nestjs/common';
+import { socialIntegrationList } from '@gitroom/nestjs-libraries/integrations/integration.manager';
+import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
+import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
+import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { AllProvidersSettings } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings';
+import { validate } from 'class-validator';
+import { Integration } from '@prisma/client';
+import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context';
+import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
+import { weightedLength } from '@gitroom/helpers/utils/count.length';
+
+function countCharacters(text: string, type: string): number {
+ if (type !== 'x') {
+ return text.length;
+ }
+ return weightedLength(text);
+}
+
+@Injectable()
+export class IntegrationSchedulePostTool implements AgentToolInterface {
+ constructor(
+ private _postsService: PostsService,
+ private _integrationService: IntegrationService
+ ) {}
+ name = 'integrationSchedulePostTool';
+
+ run() {
+ return createTool({
+ id: 'schedulePostTool',
+ description: `
+This tool allows you to schedule a post to a social media platform, based on integrationSchema tool.
+So for example:
+
+If the user want to post a post to LinkedIn with one comment
+- socialPost array length will be one
+- postsAndComments array length will be two (one for the post, one for the comment)
+
+If the user want to post 20 posts for facebook each in individual days without comments
+- socialPost array length will be 20
+- postsAndComments array length will be one
+
+If the tools return errors, you would need to rerun it with the right parameters, don't ask again, just run it
+`,
+ inputSchema: z.object({
+ socialPost: z
+ .array(
+ z.object({
+ integrationId: z
+ .string()
+ .describe('The id of the integration (not internal id)'),
+ isPremium: z
+ .boolean()
+ .describe(
+ "If the integration is X, return if it's premium or not"
+ ),
+ date: z.string().describe('The date of the post in UTC time'),
+ shortLink: z
+ .boolean()
+ .describe(
+ 'If the post has a link inside, we can ask the user if they want to add a short link'
+ ),
+ type: z
+ .enum(['draft', 'schedule', 'now'])
+ .describe(
+ 'The type of the post, if we pass now, we should pass the current date also'
+ ),
+ postsAndComments: z
+ .array(
+ z.object({
+ content: z
+ .string()
+ .describe(
+ "The content of the post, HTML, Each line must be wrapped in
here is the possible tags: h1, h2, h3, u, strong, li, ul, p (you can't have u and strong together)"
+ ),
+ attachments: z
+ .array(z.string())
+ .describe('The image of the post (URLS)'),
+ })
+ )
+ .describe(
+ 'first item is the post, every other item is the comments'
+ ),
+ settings: z
+ .array(
+ z.object({
+ key: z
+ .string()
+ .describe('Name of the settings key to pass'),
+ value: z
+ .any()
+ .describe(
+ 'Value of the key, always prefer the id then label if possible'
+ ),
+ })
+ )
+ .describe(
+ 'This relies on the integrationSchema tool to get the settings [input:settings]'
+ ),
+ })
+ )
+ .describe('Individual post'),
+ }),
+ outputSchema: z.object({
+ output: z
+ .array(
+ z.object({
+ postId: z.string(),
+ integration: z.string(),
+ })
+ )
+ .or(z.object({ errors: z.string() })),
+ }),
+ execute: async (args, options) => {
+ const { context, runtimeContext } = args;
+ checkAuth(args, options);
+ const organizationId = JSON.parse(
+ // @ts-ignore
+ runtimeContext.get('organization') as string
+ ).id;
+ const finalOutput = [];
+
+ const integrations = {} as Record;
+ for (const platform of context.socialPost) {
+ integrations[platform.integrationId] =
+ await this._integrationService.getIntegrationById(
+ organizationId,
+ platform.integrationId
+ );
+
+ const { dto, maxLength, identifier } = socialIntegrationList.find(
+ (p) =>
+ p.identifier ===
+ integrations[platform.integrationId].providerIdentifier
+ )!;
+
+ if (dto) {
+ const newDTO = new dto();
+ const obj = Object.assign(
+ newDTO,
+ platform.settings.reduce(
+ (acc, s) => ({
+ ...acc,
+ [s.key]: s.value,
+ }),
+ {} as AllProvidersSettings
+ )
+ );
+ const errors = await validate(obj);
+ if (errors.length) {
+ return {
+ errors: JSON.stringify(errors),
+ };
+ }
+
+ const errorsLength = [];
+ for (const post of platform.postsAndComments) {
+ const maximumCharacters = maxLength(platform.isPremium);
+ const strip = stripHtmlValidation('normal', post.content, true);
+ const weightedLength = countCharacters(strip, identifier || '');
+ const totalCharacters =
+ weightedLength > strip.length ? weightedLength : strip.length;
+
+ if (totalCharacters > (maximumCharacters || 1000000)) {
+ errorsLength.push({
+ value: post.content,
+ error: `The maximum characters is ${maximumCharacters}, we got ${totalCharacters}, please fix it, and try integrationSchedulePostTool again.`,
+ });
+ }
+ }
+
+ if (errorsLength.length) {
+ return {
+ errors: JSON.stringify(errorsLength),
+ };
+ }
+ }
+ }
+
+ for (const post of context.socialPost) {
+ const integration = integrations[post.integrationId];
+
+ if (!integration) {
+ throw new Error('Integration not found');
+ }
+
+ const output = await this._postsService.createPost(organizationId, {
+ date: post.date,
+ type: post.type as 'draft' | 'schedule' | 'now',
+ shortLink: post.shortLink,
+ tags: [],
+ posts: [
+ {
+ integration,
+ group: makeId(10),
+ settings: post.settings.reduce(
+ (acc, s) => ({
+ ...acc,
+ [s.key]: s.value,
+ }),
+ {} as AllProvidersSettings
+ ),
+ value: post.postsAndComments.map((p) => ({
+ content: p.content,
+ id: makeId(10),
+ image: p.attachments.map((p) => ({
+ id: makeId(10),
+ path: p,
+ })),
+ })),
+ },
+ ],
+ });
+ finalOutput.push(...output);
+ }
+
+ return {
+ output: finalOutput,
+ };
+ },
+ });
+ }
+}
diff --git a/libraries/nestjs-libraries/src/chat/tools/integration.trigger.tool.ts b/libraries/nestjs-libraries/src/chat/tools/integration.trigger.tool.ts
new file mode 100644
index 00000000..b548d22c
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/tools/integration.trigger.tool.ts
@@ -0,0 +1,156 @@
+import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface';
+import { createTool } from '@mastra/core/tools';
+import { z } from 'zod';
+import { Injectable } from '@nestjs/common';
+import {
+ IntegrationManager,
+ socialIntegrationList,
+} from '@gitroom/nestjs-libraries/integrations/integration.manager';
+import { validationMetadatasToSchemas } from 'class-validator-jsonschema';
+import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
+import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
+import { timer } from '@gitroom/helpers/utils/timer';
+import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context';
+
+@Injectable()
+export class IntegrationTriggerTool implements AgentToolInterface {
+ constructor(
+ private _integrationManager: IntegrationManager,
+ private _integrationService: IntegrationService
+ ) {}
+ name = 'triggerTool';
+
+ run() {
+ return createTool({
+ id: 'triggerTool',
+ description: `After using the integrationSchema, we sometimes miss details we can\'t ask from the user, like ids.
+ Sometimes this tool requires to user prompt for some settings, like a word to search for. methodName is required [input:callable-tools]`,
+ inputSchema: z.object({
+ integrationId: z.string().describe('The id of the integration'),
+ methodName: z
+ .string()
+ .describe(
+ 'The methodName from the `integrationSchema` functions in the tools array, required'
+ ),
+ dataSchema: z.array(
+ z.object({
+ key: z.string().describe('Name of the settings key to pass'),
+ value: z.string().describe('Value of the key'),
+ })
+ ),
+ }),
+ outputSchema: z.object({
+ output: z.array(z.record(z.string(), z.any())),
+ }),
+ execute: async (args, options) => {
+ const { context, runtimeContext } = args;
+ checkAuth(args, options);
+ console.log('triggerTool', context);
+ const organizationId = JSON.parse(
+ // @ts-ignore
+ runtimeContext.get('organization') as string
+ ).id;
+
+ const getIntegration =
+ await this._integrationService.getIntegrationById(
+ organizationId,
+ context.integrationId
+ );
+
+ if (!getIntegration) {
+ return {
+ output: 'Integration not found',
+ };
+ }
+
+ const integrationProvider = socialIntegrationList.find(
+ (p) => p.identifier === getIntegration.providerIdentifier
+ )!;
+
+ if (!integrationProvider) {
+ return {
+ output: 'Integration not found',
+ };
+ }
+
+ const tools = this._integrationManager.getAllTools();
+ if (
+ // @ts-ignore
+ !tools[integrationProvider.identifier].some(
+ (p) => p.methodName === context.methodName
+ ) ||
+ // @ts-ignore
+ !integrationProvider[context.methodName]
+ ) {
+ return { output: 'tool not found' };
+ }
+
+ while (true) {
+ try {
+ // @ts-ignore
+ const load = await integrationProvider[context.methodName](
+ getIntegration.token,
+ context.dataSchema.reduce(
+ (all, current) => ({
+ ...all,
+ [current.key]: current.value,
+ }),
+ {}
+ ),
+ getIntegration.internalId,
+ getIntegration
+ );
+
+ return { output: load };
+ } catch (err) {
+ console.log(err);
+ if (err instanceof RefreshToken) {
+ const {
+ accessToken,
+ refreshToken,
+ expiresIn,
+ additionalSettings,
+ } = await integrationProvider.refreshToken(
+ getIntegration.refreshToken
+ );
+
+ if (accessToken) {
+ await this._integrationService.createOrUpdateIntegration(
+ additionalSettings,
+ !!integrationProvider.oneTimeToken,
+ getIntegration.organizationId,
+ getIntegration.name,
+ getIntegration.picture!,
+ 'social',
+ getIntegration.internalId,
+ getIntegration.providerIdentifier,
+ accessToken,
+ refreshToken,
+ expiresIn
+ );
+
+ getIntegration.token = accessToken;
+
+ if (integrationProvider.refreshWait) {
+ await timer(10000);
+ }
+
+ continue;
+ } else {
+ await this._integrationService.disconnectChannel(
+ organizationId,
+ getIntegration
+ );
+ return {
+ output:
+ 'We had to disconnect the channel as the token expired',
+ };
+ }
+ }
+ return { output: 'Unexpected error' };
+ }
+ }
+ },
+ });
+ }
+}
diff --git a/libraries/nestjs-libraries/src/chat/tools/integration.validation.tool.ts b/libraries/nestjs-libraries/src/chat/tools/integration.validation.tool.ts
new file mode 100644
index 00000000..9d1e618c
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/tools/integration.validation.tool.ts
@@ -0,0 +1,106 @@
+import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface';
+import { createTool } from '@mastra/core/tools';
+import { z } from 'zod';
+import { Injectable } from '@nestjs/common';
+import {
+ IntegrationManager,
+ socialIntegrationList,
+} from '@gitroom/nestjs-libraries/integrations/integration.manager';
+import { validationMetadatasToSchemas } from 'class-validator-jsonschema';
+import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context';
+
+@Injectable()
+export class IntegrationValidationTool implements AgentToolInterface {
+ constructor(private _integrationManager: IntegrationManager) {}
+ name = 'integrationSchema';
+
+ run() {
+ return createTool({
+ id: 'integrationSchema',
+ description: `Everytime we want to schedule a social media post, we need to understand the schema of the integration.
+ This tool helps us get the schema of the integration.
+ Sometimes we might get a schema back the requires some id, for that, you can get information from 'tools'
+ And use the triggerTool function.
+ `,
+ inputSchema: z.object({
+ isPremium: z
+ .boolean()
+ .describe('is this the user premium? if not, set to false'),
+ platform: z
+ .string()
+ .describe(
+ `platform identifier (${socialIntegrationList
+ .map((p) => p.identifier)
+ .join(', ')})`
+ ),
+ }),
+ outputSchema: z.object({
+ output: z.object({
+ rules: z.string(),
+ maxLength: z
+ .number()
+ .describe('The maximum length of a post / comment'),
+ settings: z
+ .any()
+ .describe('List of settings need to be passed to schedule a post'),
+ tools: z
+ .array(
+ z.object({
+ description: z.string().describe('Description of the tool'),
+ methodName: z
+ .string()
+ .describe('Method to call to get the information'),
+ dataSchema: z
+ .array(
+ z.object({
+ key: z
+ .string()
+ .describe('Name of the settings key to pass'),
+ description: z
+ .string()
+ .describe('Description of the setting key'),
+ type: z.string(),
+ })
+ )
+ .describe(
+ 'This will be passed to schedulePostTool [output:settings]'
+ ),
+ })
+ )
+ .describe(
+ "Sometimes settings require some id, tags and stuff, if you don't have, trigger the `triggerTool` function from the tools list [output:callable-tools]"
+ ),
+ }),
+ }),
+ execute: async (args, options) => {
+ const { context, runtimeContext } = args;
+ checkAuth(args, options);
+ const integration = socialIntegrationList.find(
+ (p) => p.identifier === context.platform
+ )!;
+
+ if (!integration) {
+ return {
+ output: { rules: '', maxLength: 0, settings: {}, tools: [] },
+ };
+ }
+
+ const maxLength = integration.maxLength(context.isPremium);
+ const schemas = !integration.dto
+ ? false
+ : validationMetadatasToSchemas()[integration.dto.name];
+ const tools = this._integrationManager.getAllTools();
+ const rules = this._integrationManager.getAllRulesDescription();
+
+ return {
+ output: {
+ rules: rules[integration.identifier],
+ maxLength,
+ settings: !schemas ? 'No additional settings required' : schemas,
+ tools: tools[integration.identifier],
+ },
+ };
+ },
+ });
+ }
+}
diff --git a/libraries/nestjs-libraries/src/chat/tools/tool.list.ts b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts
new file mode 100644
index 00000000..beab9a75
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts
@@ -0,0 +1,19 @@
+import { IntegrationValidationTool } from '@gitroom/nestjs-libraries/chat/tools/integration.validation.tool';
+import { IntegrationTriggerTool } from '@gitroom/nestjs-libraries/chat/tools/integration.trigger.tool';
+import { IntegrationSchedulePostTool } from './integration.schedule.post';
+import { GenerateVideoOptionsTool } from '@gitroom/nestjs-libraries/chat/tools/generate.video.options.tool';
+import { VideoFunctionTool } from '@gitroom/nestjs-libraries/chat/tools/video.function.tool';
+import { GenerateVideoTool } from '@gitroom/nestjs-libraries/chat/tools/generate.video.tool';
+import { GenerateImageTool } from '@gitroom/nestjs-libraries/chat/tools/generate.image.tool';
+import { IntegrationListTool } from '@gitroom/nestjs-libraries/chat/tools/integration.list.tool';
+
+export const toolList = [
+ IntegrationListTool,
+ IntegrationValidationTool,
+ IntegrationTriggerTool,
+ IntegrationSchedulePostTool,
+ GenerateVideoOptionsTool,
+ VideoFunctionTool,
+ GenerateVideoTool,
+ GenerateImageTool,
+];
diff --git a/libraries/nestjs-libraries/src/chat/tools/video.function.tool.ts b/libraries/nestjs-libraries/src/chat/tools/video.function.tool.ts
new file mode 100644
index 00000000..2a116d91
--- /dev/null
+++ b/libraries/nestjs-libraries/src/chat/tools/video.function.tool.ts
@@ -0,0 +1,47 @@
+import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface';
+import { createTool } from '@mastra/core/tools';
+import { Injectable } from '@nestjs/common';
+import { VideoManager } from '@gitroom/nestjs-libraries/videos/video.manager';
+import z from 'zod';
+import { ModuleRef } from '@nestjs/core';
+import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context';
+
+@Injectable()
+export class VideoFunctionTool implements AgentToolInterface {
+ constructor(
+ private _videoManagerService: VideoManager,
+ private _moduleRef: ModuleRef
+ ) {}
+ name = 'videoFunctionTool';
+
+ run() {
+ return createTool({
+ id: 'videoFunctionTool',
+ description: `Sometimes when we want to generate videos we might need to get some additional information like voice_id, etc`,
+ inputSchema: z.object({
+ identifier: z.string(),
+ functionName: z.string(),
+ }),
+ execute: async (args, options) => {
+ const { context, runtimeContext } = args;
+ checkAuth(args, options);
+ const videos = this._videoManagerService.getAllVideos();
+ const findVideo = videos.find(
+ (p) =>
+ p.identifier === context.identifier &&
+ p.tools.some((p) => p.functionName === context.functionName)
+ );
+
+ if (!findVideo) {
+ return { error: 'Function not found' };
+ }
+
+ const func = await this._moduleRef
+ // @ts-ignore
+ .get(findVideo.target, { strict: false })
+ [context.functionName]();
+ return func;
+ },
+ });
+ }
+}
diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts
index c6541cdc..6024b921 100644
--- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts
@@ -15,9 +15,77 @@ export class IntegrationRepository {
private _posts: PrismaRepository<'post'>,
private _plugs: PrismaRepository<'plugs'>,
private _exisingPlugData: PrismaRepository<'exisingPlugData'>,
- private _customers: PrismaRepository<'customer'>
+ private _customers: PrismaRepository<'customer'>,
+ private _mentions: PrismaRepository<'mentions'>
) {}
+ getMentions(platform: string, q: string) {
+ return this._mentions.model.mentions.findMany({
+ where: {
+ platform,
+ OR: [
+ {
+ name: {
+ contains: q,
+ mode: 'insensitive',
+ },
+ },
+ {
+ username: {
+ contains: q,
+ mode: 'insensitive',
+ },
+ },
+ ],
+ },
+ orderBy: {
+ name: 'asc',
+ },
+ take: 100,
+ select: {
+ name: true,
+ username: true,
+ image: true,
+ },
+ });
+ }
+
+ insertMentions(
+ platform: string,
+ mentions: { name: string; username: string; image: string }[]
+ ) {
+ if (mentions.length === 0) {
+ return [] as any[];
+ }
+ return this._mentions.model.mentions.createMany({
+ data: mentions.map((mention) => ({
+ platform,
+ name: mention.name,
+ username: mention.username,
+ image: mention.image,
+ })),
+ skipDuplicates: true,
+ });
+ }
+
+ async checkPreviousConnections(org: string, id: string) {
+ const findIt = await this._integration.model.integration.findMany({
+ where: {
+ rootInternalId: id.split('_').pop(),
+ },
+ select: {
+ organizationId: true,
+ id: true,
+ },
+ });
+
+ if (findIt.some((f) => f.organizationId === org)) {
+ return false;
+ }
+
+ return findIt.length > 0;
+ }
+
updateProviderSettings(org: string, id: string, settings: string) {
return this._integration.model.integration.update({
where: {
diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts
index 2d7db625..a54758e6 100644
--- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts
@@ -46,6 +46,17 @@ export class IntegrationService {
return true;
}
+ getMentions(platform: string, q: string) {
+ return this._integrationRepository.getMentions(platform, q);
+ }
+
+ insertMentions(
+ platform: string,
+ mentions: { name: string; username: string; image: string }[]
+ ) {
+ return this._integrationRepository.insertMentions(platform, mentions);
+ }
+
async setTimes(
orgId: string,
integrationId: string,
@@ -62,6 +73,10 @@ export class IntegrationService {
);
}
+ checkPreviousConnections(org: string, id: string) {
+ return this._integrationRepository.checkPreviousConnections(org, id);
+ }
+
async createOrUpdateIntegration(
additionalSettings:
| {
@@ -163,7 +178,11 @@ export class IntegrationService {
await this.informAboutRefreshError(orgId, integration);
}
- async informAboutRefreshError(orgId: string, integration: Integration, err = '') {
+ async informAboutRefreshError(
+ orgId: string,
+ integration: Integration,
+ err = ''
+ ) {
await this._notificationService.inAppNotification(
orgId,
`Could not refresh your ${integration.providerIdentifier} channel ${err}`,
diff --git a/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts b/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts
index c6e63ef5..29d72941 100644
--- a/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts
@@ -37,7 +37,7 @@ export class MediaService {
org: Organization,
generatePromptFirst?: boolean
) {
- return await this._subscriptionService.useCredit(
+ const generating = await this._subscriptionService.useCredit(
org,
'ai_images',
async () => {
@@ -48,6 +48,8 @@ export class MediaService {
return this._openAi.generateImage(prompt, !!generatePromptFirst);
}
);
+
+ return generating;
}
saveFile(org: string, fileName: string, filePath: string) {
@@ -84,6 +86,7 @@ export class MediaService {
org,
'ai_videos'
);
+
if (totalCredits.credits <= 0) {
throw new SubscriptionException({
action: AuthorizationActions.Create,
@@ -100,7 +103,9 @@ export class MediaService {
throw new HttpException('This video is not available in trial mode', 406);
}
+ console.log(body.customParams);
await video.instance.processAndValidate(body.customParams);
+ console.log('no err');
return await this._subscriptionService.useCredit(
org,
@@ -125,8 +130,14 @@ export class MediaService {
// @ts-ignore
const functionToCall = video.instance[functionName];
- if (typeof functionToCall !== 'function' || this._videoManager.checkAvailableVideoFunction(functionToCall)) {
- throw new HttpException(`Function ${functionName} not found on video instance`, 400);
+ if (
+ typeof functionToCall !== 'function' ||
+ this._videoManager.checkAvailableVideoFunction(functionToCall)
+ ) {
+ throw new HttpException(
+ `Function ${functionName} not found on video instance`,
+ 400
+ );
}
return functionToCall(body);
diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts
index 8d4b2287..96fccaff 100644
--- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts
@@ -27,9 +27,55 @@ export class PostsRepository {
private _errors: PrismaRepository<'errors'>
) {}
+ checkPending15minutesBack() {
+ return this._post.model.post.findMany({
+ where: {
+ publishDate: {
+ lte: dayjs.utc().subtract(15, 'minute').toDate(),
+ gte: dayjs.utc().subtract(30, 'minute').toDate(),
+ },
+ state: 'QUEUE',
+ deletedAt: null,
+ parentPostId: null,
+ },
+ select: {
+ id: true,
+ publishDate: true,
+ },
+ });
+ }
+
+ searchForMissingThreeHoursPosts() {
+ return this._post.model.post.findMany({
+ where: {
+ integration: {
+ refreshNeeded: false,
+ inBetweenSteps: false,
+ disabled: false,
+ },
+ publishDate: {
+ gte: dayjs.utc().toDate(),
+ lt: dayjs.utc().add(3, 'hour').toDate(),
+ },
+ state: 'QUEUE',
+ deletedAt: null,
+ parentPostId: null,
+ },
+ select: {
+ id: true,
+ publishDate: true,
+ },
+ });
+ }
+
getOldPosts(orgId: string, date: string) {
return this._post.model.post.findMany({
where: {
+ integration: {
+ refreshNeeded: false,
+ inBetweenSteps: false,
+ disabled: false,
+ },
organizationId: orgId,
publishDate: {
lte: dayjs(date).toDate(),
@@ -394,7 +440,7 @@ export class PostsRepository {
where: {
orgId: orgId,
name: {
- in: tags.map((tag) => tag.label).filter(f => f),
+ in: tags.map((tag) => tag.label).filter((f) => f),
},
},
});
diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts
index 7bb42cb8..12163659 100644
--- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts
@@ -38,6 +38,7 @@ import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
dayjs.extend(utc);
+import * as Sentry from '@sentry/nestjs';
type PostWithConditionals = Post & {
integration?: Integration;
@@ -61,6 +62,13 @@ export class PostsService {
private openaiService: OpenaiService
) {}
+ checkPending15minutesBack() {
+ return this._postRepository.checkPending15minutesBack();
+ }
+ searchForMissingThreeHoursPosts() {
+ return this._postRepository.searchForMissingThreeHoursPosts();
+ }
+
async getStatistics(orgId: string, id: string) {
const getPost = await this.getPostsRecursively(id, true, orgId, true);
const content = getPost.map((p) => p.content);
@@ -378,7 +386,9 @@ export class PostsService {
return post;
}
- const ids = (extract || []).map((e) => e.replace('(post:', '').replace(')', ''));
+ const ids = (extract || []).map((e) =>
+ e.replace('(post:', '').replace(')', '')
+ );
const urls = await this._postRepository.getPostUrls(orgId, ids);
const newPlainText = ids.reduce((acc, value) => {
const findUrl = urls?.find?.((u) => u.id === value)?.releaseURL || '';
@@ -467,7 +477,14 @@ export class PostsService {
await Promise.all(
(newPosts || []).map(async (p) => ({
id: p.id,
- message: stripHtmlValidation(getIntegration.editor, p.content, true),
+ message: stripHtmlValidation(
+ getIntegration.editor,
+ p.content,
+ true,
+ false,
+ !/<\/?[a-z][\s\S]*>/i.test(p.content),
+ getIntegration.mentionFormat
+ ),
settings: JSON.parse(p.settings || '{}'),
media: await this.updateMedia(
p.id,
@@ -535,7 +552,12 @@ export class PostsService {
throw err;
}
- throw new BadBody(integration.providerIdentifier, JSON.stringify(err), {} as any, '');
+ throw new BadBody(
+ integration.providerIdentifier,
+ JSON.stringify(err),
+ {} as any,
+ ''
+ );
}
}
@@ -640,42 +662,6 @@ export class PostsService {
return this._postRepository.countPostsFromDay(orgId, date);
}
- async submit(
- id: string,
- order: string,
- message: string,
- integrationId: string
- ) {
- if (!(await this._messagesService.canAddPost(id, order, integrationId))) {
- throw new Error('You can not add a post to this publication');
- }
- const getOrgByOrder = await this._messagesService.getOrgByOrder(order);
- const submit = await this._postRepository.submit(
- id,
- order,
- getOrgByOrder?.messageGroup?.buyerOrganizationId!
- );
- const messageModel = await this._messagesService.createNewMessage(
- submit?.submittedForOrder?.messageGroupId || '',
- From.SELLER,
- '',
- {
- type: 'post',
- data: {
- id: order,
- postId: id,
- status: 'PENDING',
- integration: integrationId,
- description: message.slice(0, 300) + '...',
- },
- }
- );
-
- await this._postRepository.updateMessage(id, messageModel.id);
-
- return messageModel;
- }
-
async createPost(orgId: string, body: CreatePostDto): Promise {
const postList = [];
for (const post of body.posts) {
@@ -710,16 +696,6 @@ export class PostsService {
previousPost ? previousPost : posts?.[0]?.id
);
- if (body.order && body.type !== 'draft') {
- await this.submit(
- posts[0].id,
- body.order,
- post.value[0].content,
- post.integration.id
- );
- continue;
- }
-
if (
body.type === 'now' ||
(body.type === 'schedule' && dayjs(body.date).isAfter(dayjs()))
@@ -742,6 +718,7 @@ export class PostsService {
});
}
+ Sentry.metrics.count("post_created", 1);
postList.push({
postId: posts[0].id,
integration: post.integration.id,
@@ -757,17 +734,9 @@ export class PostsService {
async changeDate(orgId: string, id: string, date: string) {
const getPostById = await this._postRepository.getPostById(id, orgId);
- if (
- getPostById?.submittedForOrderId &&
- getPostById.approvedSubmitForOrder !== 'NO'
- ) {
- throw new Error(
- 'You can not change the date of a post that has been submitted'
- );
- }
await this._workerServiceProducer.delete('post', id);
- if (getPostById?.state !== 'DRAFT' && !getPostById?.submittedForOrderId) {
+ if (getPostById?.state !== 'DRAFT') {
this._workerServiceProducer.emit('post', {
id: id,
options: {
diff --git a/libraries/nestjs-libraries/src/database/prisma/prisma.service.ts b/libraries/nestjs-libraries/src/database/prisma/prisma.service.ts
index eeb4ebc3..089afc5d 100644
--- a/libraries/nestjs-libraries/src/database/prisma/prisma.service.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/prisma.service.ts
@@ -1,8 +1,8 @@
-import { Injectable, OnModuleInit } from '@nestjs/common';
+import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
-export class PrismaService extends PrismaClient implements OnModuleInit {
+export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
super({
log: [
@@ -16,6 +16,10 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
+
+ async onModuleDestroy() {
+ await this.$disconnect();
+ }
}
@Injectable()
@@ -26,7 +30,6 @@ export class PrismaRepository {
}
}
-
@Injectable()
export class PrismaTransaction {
public model: Pick;
diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma
index b63c40a3..333e6e7d 100644
--- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma
+++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma
@@ -3,6 +3,7 @@
generator client {
provider = "prisma-client-js"
+ runtime = "nodejs"
}
datasource db {
@@ -338,9 +339,14 @@ model Integration {
@@unique([organizationId, internalId])
@@index([rootInternalId])
@@index([organizationId])
+ @@index([providerIdentifier])
@@index([updatedAt])
+ @@index([createdAt])
@@index([deletedAt])
@@index([customerId])
+ @@index([inBetweenSteps])
+ @@index([refreshNeeded])
+ @@index([disabled])
}
model Signatures {
@@ -658,6 +664,18 @@ model Errors {
@@index([createdAt])
}
+model Mentions {
+ name String
+ username String
+ platform String
+ image String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@id([name, username, platform, image])
+ @@index([createdAt])
+}
+
enum OrderStatus {
PENDING
ACCEPTED
diff --git a/libraries/nestjs-libraries/src/database/prisma/signatures/signature.repository.ts b/libraries/nestjs-libraries/src/database/prisma/signatures/signature.repository.ts
index 9ccf3f0f..c6fd0523 100644
--- a/libraries/nestjs-libraries/src/database/prisma/signatures/signature.repository.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/signatures/signature.repository.ts
@@ -45,4 +45,11 @@ export class SignatureRepository {
return { id: updatedId };
}
+
+ deleteSignature(orgId: string, id: string) {
+ return this._signatures.model.signatures.update({
+ where: { id, organizationId: orgId },
+ data: { deletedAt: new Date() },
+ });
+ }
}
diff --git a/libraries/nestjs-libraries/src/database/prisma/signatures/signature.service.ts b/libraries/nestjs-libraries/src/database/prisma/signatures/signature.service.ts
index 65b55147..1dfaacbf 100644
--- a/libraries/nestjs-libraries/src/database/prisma/signatures/signature.service.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/signatures/signature.service.ts
@@ -21,4 +21,8 @@ export class SignatureService {
id
);
}
+
+ deleteSignature(orgId: string, id: string) {
+ return this._signatureRepository.deleteSignature(orgId, id);
+ }
}
diff --git a/libraries/nestjs-libraries/src/dtos/media/upload.dto.ts b/libraries/nestjs-libraries/src/dtos/media/upload.dto.ts
new file mode 100644
index 00000000..4704c094
--- /dev/null
+++ b/libraries/nestjs-libraries/src/dtos/media/upload.dto.ts
@@ -0,0 +1,9 @@
+import { IsDefined, IsString, Validate } from 'class-validator';
+import { ValidUrlExtension } from '@gitroom/helpers/utils/valid.url.path';
+
+export class UploadDto {
+ @IsString()
+ @IsDefined()
+ @Validate(ValidUrlExtension)
+ url: string;
+}
diff --git a/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts
index e966da80..dd9a8471 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts
@@ -1,20 +1,10 @@
import {
- ArrayMinSize,
- IsArray,
- IsBoolean,
- IsDateString,
- IsDefined,
- IsIn,
- IsNumber,
- IsOptional,
- IsString,
- MinLength,
- ValidateIf,
- ValidateNested,
+ ArrayMinSize, IsArray, IsBoolean, IsDateString, IsDefined, IsIn, IsNumber, IsOptional, IsString, MinLength, Validate, ValidateIf, ValidateNested
} from 'class-validator';
import { Type } from 'class-transformer';
import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto';
import { allProviders, type AllProvidersSettings, EmptySettings } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings';
+import { ValidContent } from '@gitroom/helpers/utils/valid.images';
export class Integration {
@IsDefined()
@@ -25,6 +15,7 @@ export class Integration {
export class PostContent {
@IsDefined()
@IsString()
+ @Validate(ValidContent)
content: string;
@IsOptional()
@@ -32,7 +23,6 @@ export class PostContent {
id: string;
@IsArray()
- @IsOptional()
@Type(() => MediaDto)
@ValidateNested({ each: true })
image: MediaDto[];
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts
index 3caf5e5f..0e892e60 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts
@@ -14,6 +14,7 @@ import { MediumSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/provider
import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto';
import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto';
import { WordpressDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/wordpress.dto';
+import { ListmonkDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/listmonk.dto';
export type ProviderExtension = { __type: T } & M;
export type AllProvidersSettings =
@@ -34,6 +35,7 @@ export type AllProvidersSettings =
| ProviderExtension<'devto', DevToSettingsDto>
| ProviderExtension<'hashnode', HashnodeSettingsDto>
| ProviderExtension<'wordpress', WordpressDto>
+ | ProviderExtension<'listmonk', ListmonkDto>
| ProviderExtension<'facebook', None>
| ProviderExtension<'threads', None>
| ProviderExtension<'mastodon', None>
@@ -64,6 +66,7 @@ export const allProviders = (setEmpty?: any) => {
{ value: DevToSettingsDto, name: 'devto' },
{ value: WordpressDto, name: 'wordpress' },
{ value: HashnodeSettingsDto, name: 'hashnode' },
+ { value: ListmonkDto, name: 'listmonk' },
{ value: setEmpty, name: 'facebook' },
{ value: setEmpty, name: 'threads' },
{ value: setEmpty, name: 'mastodon' },
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/discord.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/discord.dto.ts
index ed09e72a..23c40e95 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/discord.dto.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/discord.dto.ts
@@ -1,8 +1,12 @@
import { IsDefined, IsString, MinLength } from 'class-validator';
+import { JSONSchema } from 'class-validator-jsonschema';
export class DiscordDto {
@MinLength(1)
@IsDefined()
@IsString()
+ @JSONSchema({
+ description: 'Channel must be an id',
+ })
channel: string;
}
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/farcaster.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/farcaster.dto.ts
new file mode 100644
index 00000000..eaa714cb
--- /dev/null
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/farcaster.dto.ts
@@ -0,0 +1,17 @@
+import { Type } from 'class-transformer';
+import { IsString, ValidateNested } from 'class-validator';
+
+export class FarcasterId {
+ @IsString()
+ id: string;
+}
+export class FarcasterValue {
+ @ValidateNested()
+ @Type(() => FarcasterId)
+ value: FarcasterId;
+}
+export class FarcasterDto {
+ @ValidateNested({ each: true })
+ @Type(() => FarcasterValue)
+ subreddit: FarcasterValue[];
+}
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/listmonk.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/listmonk.dto.ts
new file mode 100644
index 00000000..978bf6e5
--- /dev/null
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/listmonk.dto.ts
@@ -0,0 +1,24 @@
+import { IsOptional, IsString, MinLength } from 'class-validator';
+import { JSONSchema } from 'class-validator-jsonschema';
+
+export class ListmonkDto {
+ @IsString()
+ @MinLength(1)
+ subject: string;
+
+ @IsString()
+ preview: string;
+
+ @IsString()
+ @JSONSchema({
+ description: 'List must be an id',
+ })
+ list: string;
+
+ @IsString()
+ @IsOptional()
+ @JSONSchema({
+ description: 'Template must be an id',
+ })
+ template: string;
+}
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/medium.settings.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/medium.settings.dto.ts
index 1982bedb..fdecf77e 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/medium.settings.dto.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/medium.settings.dto.ts
@@ -9,6 +9,7 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
+import { Type } from 'class-transformer';
export class MediumTagsSettings {
@IsString()
@@ -47,5 +48,7 @@ export class MediumSettingsDto {
@IsArray()
@ArrayMaxSize(4)
@IsOptional()
+ @ValidateNested({ each: true })
+ @Type(p => MediumTagsSettings)
tags: MediumTagsSettings[];
}
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts
index d6cbae14..aed79c80 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts
@@ -1,6 +1,7 @@
import {
IsDefined, IsOptional, IsString, IsUrl, MaxLength, MinLength, ValidateIf
} from 'class-validator';
+import { JSONSchema } from 'class-validator-jsonschema';
export class PinterestSettingsDto {
@IsString()
@@ -25,6 +26,9 @@ export class PinterestSettingsDto {
})
@MinLength(1, {
message: 'Board is required',
+ })
+ @JSONSchema({
+ description: 'board must be an id',
})
board: string;
}
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/reddit.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/reddit.dto.ts
index 1eeef00f..49c7462f 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/reddit.dto.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/reddit.dto.ts
@@ -9,7 +9,6 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
-import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto';
import { Type } from 'class-transformer';
export class RedditFlairDto {
@@ -56,13 +55,8 @@ export class RedditSettingsDtoInner {
@ValidateIf((e) => e.is_flair_required)
@IsDefined()
@ValidateNested()
+ @Type(() => RedditFlairDto)
flair: RedditFlairDto;
-
- @ValidateIf((e) => e.type === 'media')
- @ValidateNested({ each: true })
- @Type(() => MediaDto)
- @ArrayMinSize(1)
- media: MediaDto[];
}
export class RedditSettingsValueDto {
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/slack.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/slack.dto.ts
index ebca73b0..355718c8 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/slack.dto.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/slack.dto.ts
@@ -1,8 +1,12 @@
import { IsDefined, IsString, MinLength } from 'class-validator';
+import { JSONSchema } from 'class-validator-jsonschema';
export class SlackDto {
@MinLength(1)
@IsDefined()
@IsString()
+ @JSONSchema({
+ description: 'Channel must be an id',
+ })
channel: string;
}
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tiktok.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tiktok.dto.ts
index dfeed9e1..8417315f 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tiktok.dto.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tiktok.dto.ts
@@ -1,7 +1,9 @@
-import { IsBoolean, ValidateIf, IsIn, IsString, MaxLength } from 'class-validator';
+import {
+ IsBoolean, ValidateIf, IsIn, IsString, MaxLength, IsOptional
+} from 'class-validator';
export class TikTokDto {
- @ValidateIf(p => p.title)
+ @ValidateIf((p) => p.title)
@MaxLength(90)
title: string;
@@ -33,6 +35,10 @@ export class TikTokDto {
@IsBoolean()
brand_content_toggle: boolean;
+ @IsBoolean()
+ @IsOptional()
+ video_made_with_ai: boolean;
+
@IsBoolean()
brand_organic_toggle: boolean;
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/x.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/x.dto.ts
index db6c99f3..c4656b84 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/x.dto.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/x.dto.ts
@@ -1,9 +1,18 @@
-import { IsOptional, Matches } from 'class-validator';
+import { IsIn, IsOptional, Matches } from 'class-validator';
export class XDto {
@IsOptional()
@Matches(/^(https:\/\/x\.com\/i\/communities\/\d+)?$/, {
- message: 'Invalid X community URL. It should be in the format: https://x.com/i/communities/1493446837214187523',
+ message:
+ 'Invalid X community URL. It should be in the format: https://x.com/i/communities/1493446837214187523',
})
community?: string;
+
+ @IsIn(['everyone', 'following', 'mentionedUsers', 'subscribers', 'verified'])
+ who_can_reply_post:
+ | 'everyone'
+ | 'following'
+ | 'mentionedUsers'
+ | 'subscribers'
+ | 'verified';
}
diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/youtube.settings.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/youtube.settings.dto.ts
index 6c31adad..f1c17171 100644
--- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/youtube.settings.dto.ts
+++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/youtube.settings.dto.ts
@@ -1,11 +1,5 @@
import {
- IsArray,
- IsDefined,
- IsIn,
- IsOptional,
- IsString,
- MinLength,
- ValidateNested,
+ IsArray, IsDefined, IsIn, IsOptional, IsString, MaxLength, MinLength, ValidateNested
} from 'class-validator';
import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto';
import { Type } from 'class-transformer';
@@ -21,6 +15,7 @@ export class YoutubeTagsSettings {
export class YoutubeSettingsDto {
@IsString()
@MinLength(2)
+ @MaxLength(100)
@IsDefined()
title: string;
@@ -28,6 +23,10 @@ export class YoutubeSettingsDto {
@IsDefined()
type: string;
+ @IsIn(['yes', 'no'])
+ @IsOptional()
+ selfDeclaredMadeForKids: 'no' | 'yes';
+
@IsOptional()
@ValidateNested()
@Type(() => MediaDto)
@@ -35,5 +34,7 @@ export class YoutubeSettingsDto {
@IsArray()
@IsOptional()
+ @ValidateNested()
+ @Type(() => YoutubeTagsSettings)
tags: YoutubeTagsSettings[];
}
diff --git a/libraries/nestjs-libraries/src/emails/resend.provider.ts b/libraries/nestjs-libraries/src/emails/resend.provider.ts
index b08c0eed..c0519b0f 100644
--- a/libraries/nestjs-libraries/src/emails/resend.provider.ts
+++ b/libraries/nestjs-libraries/src/emails/resend.provider.ts
@@ -14,14 +14,20 @@ export class ResendProvider implements EmailInterface {
emailFromAddress: string,
replyTo?: string
) {
- const sends = await resend.emails.send({
- from: `${emailFromName} <${emailFromAddress}>`,
- to,
- subject,
- html,
- ...(replyTo && { reply_to: replyTo }),
- });
+ try {
+ const sends = await resend.emails.send({
+ from: `${emailFromName} <${emailFromAddress}>`,
+ to,
+ subject,
+ html,
+ ...(replyTo && { reply_to: replyTo }),
+ });
- return sends;
+ return sends;
+ } catch (err) {
+ console.log(err);
+ }
+
+ return { sent: false };
}
}
diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts
index c1ca5e73..dea5122d 100644
--- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts
+++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts
@@ -27,6 +27,7 @@ import { TelegramProvider } from '@gitroom/nestjs-libraries/integrations/social/
import { NostrProvider } from '@gitroom/nestjs-libraries/integrations/social/nostr.provider';
import { VkProvider } from '@gitroom/nestjs-libraries/integrations/social/vk.provider';
import { WordpressProvider } from '@gitroom/nestjs-libraries/integrations/social/wordpress.provider';
+import { ListmonkProvider } from '@gitroom/nestjs-libraries/integrations/social/listmonk.provider';
export const socialIntegrationList: SocialProvider[] = [
new XProvider(),
@@ -54,6 +55,7 @@ export const socialIntegrationList: SocialProvider[] = [
new DevToProvider(),
new HashnodeProvider(),
new WordpressProvider(),
+ new ListmonkProvider(),
// new MastodonCustomProvider(),
];
@@ -76,6 +78,40 @@ export class IntegrationManager {
};
}
+ getAllTools(): {
+ [key: string]: {
+ description: string;
+ dataSchema: any;
+ methodName: string;
+ }[];
+ } {
+ return socialIntegrationList.reduce(
+ (all, current) => ({
+ ...all,
+ [current.identifier]:
+ Reflect.getMetadata('custom:tool', current.constructor.prototype) ||
+ [],
+ }),
+ {}
+ );
+ }
+
+ getAllRulesDescription(): {
+ [key: string]: string;
+ } {
+ return socialIntegrationList.reduce(
+ (all, current) => ({
+ ...all,
+ [current.identifier]:
+ Reflect.getMetadata(
+ 'custom:rules:description',
+ current.constructor
+ ) || '',
+ }),
+ {}
+ );
+ }
+
getAllPlugs() {
return socialIntegrationList
.map((p) => {
diff --git a/libraries/nestjs-libraries/src/integrations/social.abstract.ts b/libraries/nestjs-libraries/src/integrations/social.abstract.ts
index dc45d39b..94bb51b0 100644
--- a/libraries/nestjs-libraries/src/integrations/social.abstract.ts
+++ b/libraries/nestjs-libraries/src/integrations/social.abstract.ts
@@ -1,5 +1,6 @@
import { timer } from '@gitroom/helpers/utils/timer';
-import { concurrencyService } from '@gitroom/helpers/utils/concurrency.service';
+import { concurrency } from '@gitroom/helpers/utils/concurrency.service';
+import { Integration } from '@prisma/client';
export class RefreshToken {
constructor(
@@ -24,24 +25,49 @@ export class NotEnoughScopes {
export abstract class SocialAbstract {
abstract identifier: string;
+ maxConcurrentJob = 1;
public handleErrors(
body: string
- ): { type: 'refresh-token' | 'bad-body'; value: string } | undefined {
+ ):
+ | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string }
+ | undefined {
return undefined;
}
- async runInConcurrent(func: (...args: any[]) => Promise) {
- const value = await concurrencyService(this.identifier.split('-')[0], async () => {
- try {
- return await func();
- } catch (err) {
- return {type: 'error', value: err};
- }
- });
+ public async mention(
+ token: string,
+ d: { query: string },
+ id: string,
+ integration: Integration
+ ): Promise<
+ | { id: string; label: string; image: string; doNotCache?: boolean }[]
+ | { none: true }
+ > {
+ return { none: true };
+ }
- if (value && value.type === 'error') {
- throw value.value;
+ async runInConcurrent(
+ func: (...args: any[]) => Promise,
+ ignoreConcurrency?: boolean
+ ) {
+ const value = await concurrency(
+ this.identifier,
+ this.maxConcurrentJob,
+ async () => {
+ try {
+ return await func();
+ } catch (err) {
+ console.log(err);
+ const handle = this.handleErrors(JSON.stringify(err));
+ return { err: true, ...(handle || {}) };
+ }
+ },
+ ignoreConcurrency
+ );
+
+ if (value && value?.err && value?.value) {
+ throw new BadBody('', JSON.stringify({}), {} as any, value.value || '');
}
return value;
@@ -51,11 +77,14 @@ export abstract class SocialAbstract {
url: string,
options: RequestInit = {},
identifier = '',
- totalRetries = 0
+ totalRetries = 0,
+ ignoreConcurrency = false
): Promise {
- const request = await concurrencyService(
- this.identifier.split('-')[0],
- () => fetch(url, options)
+ const request = await concurrency(
+ this.identifier,
+ this.maxConcurrentJob,
+ () => fetch(url, options),
+ ignoreConcurrency
);
if (request.status === 200 || request.status === 201) {
@@ -73,13 +102,23 @@ export abstract class SocialAbstract {
json = '{}';
}
- if (request.status === 500 || json.includes('rate_limit_exceeded') || json.includes('Rate limit')) {
+ if (
+ request.status === 429 ||
+ request.status === 500 ||
+ json.includes('rate_limit_exceeded') ||
+ json.includes('Rate limit')
+ ) {
await timer(5000);
- return this.fetch(url, options, identifier, totalRetries + 1);
+ return this.fetch(url, options, identifier, totalRetries + 1, ignoreConcurrency);
}
const handleError = this.handleErrors(json || '{}');
+ if (handleError?.type === 'retry') {
+ await timer(5000);
+ return this.fetch(url, options, identifier, totalRetries + 1, ignoreConcurrency);
+ }
+
if (
request.status === 401 &&
(handleError?.type === 'refresh-token' || !handleError)
diff --git a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
index 026d4970..3e1aa59b 100644
--- a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
@@ -6,16 +6,17 @@ import {
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import {
+ BadBody,
RefreshToken,
SocialAbstract,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
-import {
- BskyAgent,
- RichText,
+import {
+ BskyAgent,
+ RichText,
AppBskyEmbedVideo,
AppBskyVideoDefs,
AtpAgent,
- BlobRef
+ BlobRef,
} from '@atproto/api';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
@@ -25,6 +26,7 @@ import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { timer } from '@gitroom/helpers/utils/timer';
import axios from 'axios';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
+import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
async function reduceImageBySize(url: string, maxSizeKB = 976) {
try {
@@ -52,23 +54,26 @@ async function reduceImageBySize(url: string, maxSizeKB = 976) {
if (width < 10 || height < 10) break; // Prevent overly small dimensions
}
- return imageBuffer;
+ return { width, height, buffer: imageBuffer };
} catch (error) {
console.error('Error processing image:', error);
throw error;
}
}
-async function uploadVideo(agent: AtpAgent, videoPath: string): Promise {
- const { data: serviceAuth } = await agent.com.atproto.server.getServiceAuth(
- {
- aud: `did:web:${agent.dispatchUrl.host}`,
- lxm: "com.atproto.repo.uploadBlob",
- exp: Date.now() / 1000 + 60 * 30, // 30 minutes
- },
- );
+async function uploadVideo(
+ agent: AtpAgent,
+ videoPath: string
+): Promise {
+ const { data: serviceAuth } = await agent.com.atproto.server.getServiceAuth({
+ aud: `did:web:${agent.dispatchUrl.host}`,
+ lxm: 'com.atproto.repo.uploadBlob',
+ exp: Date.now() / 1000 + 60 * 30, // 30 minutes
+ });
- async function downloadVideo(url: string): Promise<{ video: Buffer, size: number }> {
+ async function downloadVideo(
+ url: string
+ ): Promise<{ video: Buffer; size: number }> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch video: ${response.statusText}`);
@@ -81,57 +86,75 @@ async function uploadVideo(agent: AtpAgent, videoPath: string): Promise setTimeout(resolve, 1000));
+
+ if (status.jobStatus.state === 'JOB_STATE_FAILED') {
+ throw new BadBody(
+ 'bluesky',
+ JSON.stringify({}),
+ {} as any,
+ 'Could not upload video, job failed'
+ );
+ }
+
+ await timer(30000);
}
-
- console.log("posting video...");
+
+ console.log('posting video...');
return {
- $type: "app.bsky.embed.video",
+ $type: 'app.bsky.embed.video',
video: blob,
} satisfies AppBskyEmbedVideo.Main;
}
+@Rules(
+ 'Bluesky can have maximum 1 video or 4 pictures in one post, it can also be without attachments'
+)
export class BlueskyProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 2; // Bluesky has moderate rate limits
identifier = 'bluesky';
name = 'Bluesky';
isBetweenSteps = false;
scopes = ['write:statuses', 'profile', 'write:media'];
editor = 'normal' as const;
+ maxLength() {
+ return 300;
+ }
async customFields() {
return [
@@ -207,7 +230,7 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
accessToken: accessJwt,
id: did,
name: profile.data.displayName!,
- picture: profile.data.avatar!,
+ picture: profile?.data?.avatar || '',
username: profile.data.handle!,
};
} catch (e) {
@@ -240,18 +263,25 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
let loadCid = '';
let loadUri = '';
+ let replyCid = '';
+ let replyUri = '';
const cidUrl = [] as { cid: string; url: string; rev: string }[];
for (const post of postDetails) {
// Separate images and videos
- const imageMedia = post.media?.filter((p) => p.path.indexOf('mp4') === -1) || [];
- const videoMedia = post.media?.filter((p) => p.path.indexOf('mp4') !== -1) || [];
+ const imageMedia =
+ post.media?.filter((p) => p.path.indexOf('mp4') === -1) || [];
+ const videoMedia =
+ post.media?.filter((p) => p.path.indexOf('mp4') !== -1) || [];
// Upload images
const images = await Promise.all(
imageMedia.map(async (p) => {
- return await agent.uploadBlob(
- new Blob([await reduceImageBySize(p.path)])
- );
+ const { buffer, width, height } = await reduceImageBySize(p.path);
+ return {
+ width,
+ height,
+ buffer: await agent.uploadBlob(new Blob([buffer])),
+ };
})
);
@@ -278,7 +308,11 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
$type: 'app.bsky.embed.images',
images: images.map((p, index) => ({
alt: imageMedia?.[index]?.alt || '',
- image: p.data.blob,
+ image: p.buffer.data.blob,
+ aspectRatio: {
+ width: p.width,
+ height: p.height,
+ },
})),
};
}
@@ -293,8 +327,8 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
? {
reply: {
root: {
- uri: loadUri,
- cid: loadCid,
+ uri: replyUri,
+ cid: replyCid,
},
parent: {
uri: loadUri,
@@ -307,13 +341,19 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
loadCid = loadCid || cid;
loadUri = loadUri || uri;
+ replyCid = cid;
+ replyUri = uri;
cidUrl.push({ cid, url: uri, rev: commit.rev });
}
if (postDetails?.[0]?.settings?.active_thread_finisher) {
const rt = new RichText({
- text: stripHtmlValidation('normal', postDetails?.[0]?.settings?.thread_finisher, true),
+ text: stripHtmlValidation(
+ 'normal',
+ postDetails?.[0]?.settings?.thread_finisher,
+ true
+ ),
});
await rt.detectFacets(agent);
@@ -487,4 +527,38 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
return true;
}
+
+ override async mention(
+ token: string,
+ d: { query: string },
+ id: string,
+ integration: Integration
+ ) {
+ const body = JSON.parse(
+ AuthService.fixedDecryption(integration.customInstanceDetails!)
+ );
+
+ const agent = new BskyAgent({
+ service: body.service,
+ });
+
+ await agent.login({
+ identifier: body.identifier,
+ password: body.password,
+ });
+
+ const list = await agent.searchActors({
+ q: d.query,
+ });
+
+ return list.data.actors.map((p) => ({
+ label: p.displayName,
+ id: p.handle,
+ image: p.avatar,
+ }));
+ }
+
+ mentionFormat(idOrHandle: string, name: string) {
+ return `@${idOrHandle}`;
+ }
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts
index 76f9c29c..9f59d671 100644
--- a/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts
@@ -8,13 +8,20 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
export class DevToProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 3; // Dev.to has moderate publishing limits
identifier = 'devto';
name = 'Dev.to';
isBetweenSteps = false;
editor = 'markdown' as const;
scopes = [] as string[];
+ maxLength() {
+ return 100000;
+ }
+ dto = DevToSettingsDto;
async generateAuthUrl() {
const state = makeId(6);
@@ -29,7 +36,7 @@ export class DevToProvider extends SocialAbstract implements SocialProvider {
if (body.indexOf('Canonical url has already been taken') > -1) {
return {
type: 'bad-body' as const,
- value: 'Canonical URL already exists'
+ value: 'Canonical URL already exists',
};
}
@@ -80,7 +87,7 @@ export class DevToProvider extends SocialAbstract implements SocialProvider {
accessToken: body.apiKey,
id,
name,
- picture: profile_image,
+ picture: profile_image || '',
username,
};
} catch (err) {
@@ -88,6 +95,7 @@ export class DevToProvider extends SocialAbstract implements SocialProvider {
}
}
+ @Tool({ description: 'Tag list', dataSchema: [] })
async tags(token: string) {
const tags = await (
await fetch('https://dev.to/api/tags?per_page=1000&page=1', {
@@ -100,6 +108,7 @@ export class DevToProvider extends SocialAbstract implements SocialProvider {
return tags.map((p: any) => ({ value: p.id, label: p.name }));
}
+ @Tool({ description: 'Organization list', dataSchema: [] })
async organizations(token: string) {
const orgs = await (
await fetch('https://dev.to/api/articles/me/all?per_page=1000', {
diff --git a/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts b/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts
index 6d8cfe9f..e1692347 100644
--- a/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts
@@ -6,13 +6,22 @@ import {
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
+import { Integration } from '@prisma/client';
+import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
export class DiscordProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 5; // Discord has generous rate limits for webhook posting
identifier = 'discord';
name = 'Discord';
isBetweenSteps = false;
editor = 'markdown' as const;
scopes = ['identify', 'guilds'];
+ maxLength() {
+ return 1980;
+ }
+ dto = DiscordDto;
+
async refreshToken(refreshToken: string): Promise {
const { access_token, expires_in, refresh_token } = await (
await this.fetch('https://discord.com/api/oauth2/token', {
@@ -108,6 +117,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
};
}
+ @Tool({ description: 'Channels', dataSchema: [] })
async channels(accessToken: string, params: any, id: string) {
const list = await (
await fetch(`https://discord.com/api/guilds/${id}/channels`, {
@@ -158,7 +168,9 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
form.append(
'payload_json',
JSON.stringify({
- content: post.message,
+ content: post.message.replace(/\[\[\[(@.*?)]]]/g, (match, p1) => {
+ return `<${p1}>`;
+ }),
attachments: post.media?.map((p, index) => ({
id: index,
description: `Picture ${index}`,
@@ -218,4 +230,75 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
name,
};
}
+
+ override async mention(
+ token: string,
+ data: { query: string },
+ id: string,
+ integration: Integration
+ ) {
+ const allRoles = await (
+ await fetch(`https://discord.com/api/guilds/${id}/roles`, {
+ headers: {
+ Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
+ 'Content-Type': 'application/json',
+ },
+ })
+ ).json();
+
+ const matching = allRoles
+ .filter((role: any) =>
+ role.name.toLowerCase().includes(data.query.toLowerCase())
+ )
+ .filter((f: any) => f.name !== '@everyone' && f.name !== '@here');
+
+ const list = await (
+ await fetch(
+ `https://discord.com/api/guilds/${id}/members/search?query=${data.query}`,
+ {
+ headers: {
+ Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
+ 'Content-Type': 'application/json',
+ },
+ }
+ )
+ ).json();
+
+ return [
+ ...[
+ {
+ id: String('here'),
+ label: 'here',
+ image: '',
+ doNotCache: true,
+ },
+ {
+ id: String('everyone'),
+ label: 'everyone',
+ image: '',
+ doNotCache: true,
+ },
+ ].filter((role: any) => {
+ return role.label.toLowerCase().includes(data.query.toLowerCase());
+ }),
+ ...matching.map((p: any) => ({
+ id: String('&' + p.id),
+ label: p.name.split('@')[1],
+ image: '',
+ doNotCache: true,
+ })),
+ ...list.map((p: any) => ({
+ id: String(p.user.id),
+ label: p.user.global_name || p.user.username,
+ image: `https://cdn.discordapp.com/avatars/${p.user.id}/${p.user.avatar}.png`,
+ })),
+ ];
+ }
+
+ mentionFormat(idOrHandle: string, name: string) {
+ if (name === '@here' || name === '@everyone') {
+ return name;
+ }
+ return `[[[@${idOrHandle.replace('@', '')}]]]`;
+ }
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts
index 4dcab6b9..4b2574ed 100644
--- a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts
@@ -11,13 +11,20 @@ import FormData from 'form-data';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { DribbbleDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dribbble.dto';
import mime from 'mime-types';
+import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
export class DribbbleProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 3; // Dribbble has moderate API limits
identifier = 'dribbble';
name = 'Dribbble';
isBetweenSteps = false;
scopes = ['public', 'upload'];
editor = 'normal' as const;
+ maxLength() {
+ return 40000;
+ }
+ dto = DribbbleDto;
async refreshToken(refreshToken: string): Promise {
const { access_token, expires_in } = await (
@@ -53,11 +60,12 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
accessToken: access_token,
refreshToken: refreshToken,
expiresIn: expires_in,
- picture: profile_image,
+ picture: profile_image || '',
username,
};
}
+ @Tool({ description: 'Teams list', dataSchema: [] })
async teams(accessToken: string) {
const { teams } = await (
await this.fetch('https://api.dribbble.com/v2/user', {
diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts
index acbe33d6..bebd91a2 100644
--- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts
@@ -9,6 +9,7 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { FacebookDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/facebook.dto';
+import { DribbbleDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dribbble.dto';
export class FacebookProvider extends SocialAbstract implements SocialProvider {
identifier = 'facebook';
@@ -22,7 +23,12 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
'pages_read_engagement',
'read_insights',
];
+ override maxConcurrentJob = 3; // Facebook has reasonable rate limits
editor = 'normal' as const;
+ maxLength() {
+ return 63206;
+ }
+ dto = FacebookDto;
override handleErrors(body: string):
| {
@@ -77,7 +83,8 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
if (body.indexOf('1404006') > -1) {
return {
type: 'bad-body' as const,
- value: "We couldn't post your comment, A security check in facebook required to proceed.",
+ value:
+ "We couldn't post your comment, A security check in facebook required to proceed.",
};
}
@@ -198,7 +205,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
refresh?: string;
}) {
const getAccessToken = await (
- await this.fetch(
+ await fetch(
'https://graph.facebook.com/v20.0/oauth/access_token' +
`?client_id=${process.env.FACEBOOK_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
@@ -212,7 +219,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
).json();
const { access_token } = await (
- await this.fetch(
+ await fetch(
'https://graph.facebook.com/v20.0/oauth/access_token' +
'?grant_type=fb_exchange_token' +
`&client_id=${process.env.FACEBOOK_APP_ID}` +
@@ -222,7 +229,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
).json();
const { data } = await (
- await this.fetch(
+ await fetch(
`https://graph.facebook.com/v20.0/me/permissions?access_token=${access_token}`
)
).json();
@@ -232,14 +239,8 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
.map((p: any) => p.permission);
this.checkScopes(this.scopes, permissions);
- const {
- id,
- name,
- picture: {
- data: { url },
- },
- } = await (
- await this.fetch(
+ const { id, name, picture } = await (
+ await fetch(
`https://graph.facebook.com/v20.0/me?fields=id,name,picture&access_token=${access_token}`
)
).json();
@@ -250,14 +251,14 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
accessToken: access_token,
refreshToken: access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
- picture: url,
+ picture: picture?.data?.url || '',
username: '',
};
}
async pages(accessToken: string) {
const { data } = await (
- await this.fetch(
+ await fetch(
`https://graph.facebook.com/v20.0/me/accounts?fields=id,username,name,picture.type(large)&access_token=${accessToken}`
)
).json();
@@ -275,7 +276,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
data: { url },
},
} = await (
- await this.fetch(
+ await fetch(
`https://graph.facebook.com/v20.0/${pageId}?fields=username,access_token,name,picture.type(large)&access_token=${accessToken}`
)
).json();
@@ -428,7 +429,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
const since = dayjs().subtract(date, 'day').unix();
const { data } = await (
- await this.fetch(
+ await fetch(
`https://graph.facebook.com/v20.0/${id}/insights?metric=page_impressions_unique,page_posts_impressions_unique,page_post_engagements,page_daily_follows,page_video_views&access_token=${accessToken}&period=day&since=${since}&until=${until}`
)
).json();
diff --git a/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts b/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts
index 18b89a86..32e68bfa 100644
--- a/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts
@@ -11,11 +11,17 @@ import { NeynarAPIClient } from '@neynar/nodejs-sdk';
import { Integration } from '@prisma/client';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { groupBy } from 'lodash';
+import { FarcasterDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/farcaster.dto';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
+import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
const client = new NeynarAPIClient({
apiKey: process.env.NEYNAR_SECRET_KEY || '00000000-000-0000-000-000000000000',
});
+@Rules(
+ 'Farcaster/Warpcast can only accept pictures'
+)
export class FarcasterProvider
extends SocialAbstract
implements SocialProvider
@@ -25,7 +31,12 @@ export class FarcasterProvider
isBetweenSteps = false;
isWeb3 = true;
scopes = [] as string[];
+ override maxConcurrentJob = 3; // Farcaster has moderate limits
editor = 'normal' as const;
+ maxLength() {
+ return 800;
+ }
+ dto = FarcasterDto;
async refreshToken(refresh_token: string): Promise {
return {
@@ -60,7 +71,7 @@ export class FarcasterProvider
accessToken: data.signer_uuid,
refreshToken: '',
expiresIn: dayjs().add(200, 'year').unix() - dayjs().unix(),
- picture: data.pfp_url,
+ picture: data?.pfp_url || '',
username: data.username,
};
}
@@ -68,7 +79,7 @@ export class FarcasterProvider
async post(
id: string,
accessToken: string,
- postDetails: PostDetails[]
+ postDetails: PostDetails[]
): Promise {
const ids = [];
const subreddit =
@@ -114,6 +125,10 @@ export class FarcasterProvider
return list;
}
+ @Tool({
+ description: 'Search channels',
+ dataSchema: [{ key: 'word', type: 'string', description: 'Search word' }],
+ })
async subreddits(
accessToken: string,
data: any,
diff --git a/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts
index 55e097f4..41c0c80f 100644
--- a/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts
@@ -11,13 +11,19 @@ import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/provid
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
export class HashnodeProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 3; // Hashnode has lenient publishing limits
identifier = 'hashnode';
name = 'Hashnode';
isBetweenSteps = false;
scopes = [] as string[];
editor = 'markdown' as const;
+ maxLength() {
+ return 10000;
+ }
+ dto = HashnodeSettingsDto;
async generateAuthUrl() {
const state = makeId(6);
@@ -90,7 +96,7 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider {
accessToken: body.apiKey,
id,
name,
- picture: profilePicture,
+ picture: profilePicture || '',
username,
};
} catch (err) {
@@ -102,6 +108,12 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider {
return tags.map((tag) => ({ value: tag.objectID, label: tag.name }));
}
+ @Tool({ description: 'Tags', dataSchema: [] })
+ tagsList() {
+ return tags;
+ }
+
+ @Tool({ description: 'Publications', dataSchema: [] })
async publications(accessToken: string) {
const {
data: {
diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts
index f4a01c9e..2cc499e2 100644
--- a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts
@@ -11,7 +11,11 @@ import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { InstagramDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/instagram.dto';
import { Integration } from '@prisma/client';
+import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
+@Rules(
+ "Instagram should have at least one attachment, if it's a story, it can have only one picture"
+)
export class InstagramProvider
extends SocialAbstract
implements SocialProvider
@@ -29,7 +33,12 @@ export class InstagramProvider
'instagram_manage_comments',
'instagram_manage_insights',
];
+ override maxConcurrentJob = 10;
editor = 'normal' as const;
+ dto = InstagramDto;
+ maxLength() {
+ return 2200;
+ }
async refreshToken(refresh_token: string): Promise {
return {
@@ -45,10 +54,16 @@ export class InstagramProvider
public override handleErrors(body: string):
| {
- type: 'refresh-token' | 'bad-body';
+ type: 'refresh-token' | 'bad-body' | 'retry';
value: string;
}
| undefined {
+ if (body.indexOf('An unknown error occurred') > -1) {
+ return {
+ type: 'retry' as const,
+ value: 'An unknown error occurred, please try again later',
+ };
+ }
if (body.indexOf('REVOKED_ACCESS_TOKEN') > -1) {
return {
@@ -58,7 +73,9 @@ export class InstagramProvider
};
}
- if (body.toLowerCase().indexOf('the user is not an instagram business') > -1) {
+ if (
+ body.toLowerCase().indexOf('the user is not an instagram business') > -1
+ ) {
return {
type: 'refresh-token' as const,
value:
@@ -326,7 +343,7 @@ export class InstagramProvider
refresh: string;
}) {
const getAccessToken = await (
- await this.fetch(
+ await fetch(
'https://graph.facebook.com/v20.0/oauth/access_token' +
`?client_id=${process.env.FACEBOOK_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
@@ -340,7 +357,7 @@ export class InstagramProvider
).json();
const { access_token, expires_in, ...all } = await (
- await this.fetch(
+ await fetch(
'https://graph.facebook.com/v20.0/oauth/access_token' +
'?grant_type=fb_exchange_token' +
`&client_id=${process.env.FACEBOOK_APP_ID}` +
@@ -350,7 +367,7 @@ export class InstagramProvider
).json();
const { data } = await (
- await this.fetch(
+ await fetch(
`https://graph.facebook.com/v20.0/me/permissions?access_token=${access_token}`
)
).json();
@@ -360,14 +377,8 @@ export class InstagramProvider
.map((p: any) => p.permission);
this.checkScopes(this.scopes, permissions);
- const {
- id,
- name,
- picture: {
- data: { url },
- },
- } = await (
- await this.fetch(
+ const { id, name, picture } = await (
+ await fetch(
`https://graph.facebook.com/v20.0/me?fields=id,name,picture&access_token=${access_token}`
)
).json();
@@ -378,14 +389,14 @@ export class InstagramProvider
accessToken: access_token,
refreshToken: access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
- picture: url,
+ picture: picture?.data?.url || '',
username: '',
};
}
async pages(accessToken: string) {
const { data } = await (
- await this.fetch(
+ await fetch(
`https://graph.facebook.com/v20.0/me/accounts?fields=id,instagram_business_account,username,name,picture.type(large)&access_token=${accessToken}&limit=500`
)
).json();
@@ -397,7 +408,7 @@ export class InstagramProvider
return {
pageId: p.id,
...(await (
- await this.fetch(
+ await fetch(
`https://graph.facebook.com/v20.0/${p.instagram_business_account.id}?fields=name,profile_picture_url&access_token=${accessToken}&limit=500`
)
).json()),
@@ -419,18 +430,17 @@ export class InstagramProvider
data: { pageId: string; id: string }
) {
const { access_token, ...all } = await (
- await this.fetch(
+ await fetch(
`https://graph.facebook.com/v20.0/${data.pageId}?fields=access_token,name,picture.type(large)&access_token=${accessToken}`
)
).json();
const { id, name, profile_picture_url, username } = await (
- await this.fetch(
+ await fetch(
`https://graph.facebook.com/v20.0/${data.id}?fields=username,name,profile_picture_url&access_token=${accessToken}`
)
).json();
- console.log(id, name, profile_picture_url, username);
return {
id,
name,
@@ -498,10 +508,14 @@ export class InstagramProvider
while (status === 'IN_PROGRESS') {
const { status_code } = await (
await this.fetch(
- `https://${type}/v20.0/${photoId}?access_token=${accessToken}&fields=status_code`
+ `https://${type}/v20.0/${photoId}?access_token=${accessToken}&fields=status_code`,
+ undefined,
+ '',
+ 0,
+ true
)
).json();
- await timer(10000);
+ await timer(30000);
status = status_code;
}
console.log('in progress3', id);
@@ -558,10 +572,14 @@ export class InstagramProvider
while (status === 'IN_PROGRESS') {
const { status_code } = await (
await this.fetch(
- `https://${type}/v20.0/${containerId}?fields=status_code&access_token=${accessToken}`
+ `https://${type}/v20.0/${containerId}?fields=status_code&access_token=${accessToken}`,
+ undefined,
+ '',
+ 0,
+ true
)
).json();
- await timer(10000);
+ await timer(30000);
status = status_code;
}
@@ -605,7 +623,7 @@ export class InstagramProvider
).json();
arr.push({
- id: firstPost.id,
+ id: post.id,
postId: commentId,
releaseURL: linkGlobal,
status: 'success',
@@ -617,44 +635,44 @@ export class InstagramProvider
private setTitle(name: string) {
switch (name) {
- case "likes": {
+ case 'likes': {
return 'Likes';
}
- case "followers": {
+ case 'followers': {
return 'Followers';
}
- case "reach": {
+ case 'reach': {
return 'Reach';
}
- case "follower_count": {
+ case 'follower_count': {
return 'Follower Count';
}
- case "views": {
+ case 'views': {
return 'Views';
}
- case "comments": {
+ case 'comments': {
return 'Comments';
}
- case "shares": {
+ case 'shares': {
return 'Shares';
}
- case "saves": {
+ case 'saves': {
return 'Saves';
}
- case "replies": {
+ case 'replies': {
return 'Replies';
}
}
- return "";
+ return '';
}
async analytics(
@@ -667,13 +685,13 @@ export class InstagramProvider
const since = dayjs().subtract(date, 'day').unix();
const { data, ...all } = await (
- await this.fetch(
+ await fetch(
`https://${type}/v21.0/${id}/insights?metric=follower_count,reach&access_token=${accessToken}&period=day&since=${since}&until=${until}`
)
).json();
const { data: data2, ...all2 } = await (
- await this.fetch(
+ await fetch(
`https://${type}/v21.0/${id}/insights?metric_type=total_value&metric=likes,views,comments,shares,saves,replies&access_token=${accessToken}&period=day&since=${since}&until=${until}`
)
).json();
diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts
index b5afc47a..bc058502 100644
--- a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts
@@ -10,9 +10,13 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab
import { InstagramDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/instagram.dto';
import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider';
import { Integration } from '@prisma/client';
+import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
const instagramProvider = new InstagramProvider();
+@Rules(
+ "Instagram should have at least one attachment, if it's a story, it can have only one picture"
+)
export class InstagramStandaloneProvider
extends SocialAbstract
implements SocialProvider
@@ -26,22 +30,36 @@ export class InstagramStandaloneProvider
'instagram_business_manage_comments',
'instagram_business_manage_insights',
];
+ override maxConcurrentJob = 10; // Instagram standalone has stricter limits
+ dto = InstagramDto;
editor = 'normal' as const;
+ maxLength() {
+ return 2200;
+ }
- public override handleErrors(body: string): { type: "refresh-token" | "bad-body"; value: string } | undefined {
+ public override handleErrors(
+ body: string
+ ):
+ | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string }
+ | undefined {
return instagramProvider.handleErrors(body);
}
async refreshToken(refresh_token: string): Promise {
const { access_token } = await (
- await this.fetch(
+ await fetch(
`https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=${refresh_token}`
)
).json();
- const { user_id, name, username, profile_picture_url } = await (
- await this.fetch(
+ const {
+ user_id,
+ name,
+ username,
+ profile_picture_url = '',
+ } = await (
+ await fetch(
`https://graph.instagram.com/v21.0/me?fields=user_id,username,name,profile_picture_url&access_token=${access_token}`
)
).json();
@@ -52,7 +70,7 @@ export class InstagramStandaloneProvider
accessToken: access_token,
refreshToken: access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
- picture: profile_picture_url,
+ picture: profile_picture_url || '',
username,
};
}
@@ -97,14 +115,14 @@ export class InstagramStandaloneProvider
formData.append('code', params.code);
const getAccessToken = await (
- await this.fetch('https://api.instagram.com/oauth/access_token', {
+ await fetch('https://api.instagram.com/oauth/access_token', {
method: 'POST',
body: formData,
})
).json();
const { access_token, expires_in, ...all } = await (
- await this.fetch(
+ await fetch(
'https://graph.instagram.com/access_token' +
'?grant_type=ig_exchange_token' +
`&client_id=${process.env.INSTAGRAM_APP_ID}` +
@@ -116,7 +134,7 @@ export class InstagramStandaloneProvider
this.checkScopes(this.scopes, getAccessToken.permissions);
const { user_id, name, username, profile_picture_url } = await (
- await this.fetch(
+ await fetch(
`https://graph.instagram.com/v21.0/me?fields=user_id,username,name,profile_picture_url&access_token=${access_token}`
)
).json();
diff --git a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts
index 56d46aee..da71b53a 100644
--- a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts
@@ -11,13 +11,19 @@ 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 {
+ override maxConcurrentJob = 3; // Lemmy instances typically have moderate limits
identifier = 'lemmy';
name = 'Lemmy';
isBetweenSteps = false;
scopes = [] as string[];
editor = 'normal' as const;
+ maxLength() {
+ return 10000;
+ }
+ dto = LemmySettingsDto;
async customFields() {
return [
@@ -106,7 +112,7 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider {
user.person_view.person.display_name ||
user.person_view.person.name ||
'',
- picture: user.person_view.person.avatar || '',
+ picture: user?.person_view?.person?.avatar || '',
username: body.identifier || '',
};
} catch (e) {
@@ -202,6 +208,16 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider {
}));
}
+ @Tool({
+ description: 'Search for Lemmy communities by keyword',
+ dataSchema: [
+ {
+ key: 'word',
+ type: 'string',
+ description: 'Keyword to search for',
+ },
+ ],
+ })
async subreddits(
accessToken: string,
data: any,
diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts
index 96109f6a..fdb66eb6 100644
--- a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts
@@ -11,7 +11,11 @@ import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { timer } from '@gitroom/helpers/utils/timer';
+import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
+@Rules(
+ 'LinkedIn can have maximum one attachment when selecting video, when choosing a carousel on LinkedIn minimum amount of attachment must be two, and only pictures, if uploading a video, LinkedIn can have only one attachment'
+)
export class LinkedinPageProvider
extends LinkedinProvider
implements SocialProvider
@@ -20,6 +24,7 @@ export class LinkedinPageProvider
override name = 'LinkedIn Page';
override isBetweenSteps = true;
override refreshWait = true;
+ override maxConcurrentJob = 2; // LinkedIn Page has professional posting limits
override scopes = [
'openid',
'profile',
@@ -30,7 +35,7 @@ export class LinkedinPageProvider
'r_organization_social',
];
- editor = 'normal' as const;
+ override editor = 'normal' as const;
override async refreshToken(
refresh_token: string
@@ -264,7 +269,7 @@ export class LinkedinPageProvider
const startDate = dayjs().subtract(date, 'days').unix() * 1000;
const { elements }: { elements: Root[]; paging: any } = await (
- await this.fetch(
+ await fetch(
`https://api.linkedin.com/v2/organizationPageStatistics?q=organization&organization=${encodeURIComponent(
`urn:li:organization:${id}`
)}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`,
@@ -279,7 +284,7 @@ export class LinkedinPageProvider
).json();
const { elements: elements2 }: { elements: Root[]; paging: any } = await (
- await this.fetch(
+ await fetch(
`https://api.linkedin.com/v2/organizationalEntityFollowerStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(
`urn:li:organization:${id}`
)}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`,
@@ -294,7 +299,7 @@ export class LinkedinPageProvider
).json();
const { elements: elements3 }: { elements: Root[]; paging: any } = await (
- await this.fetch(
+ await fetch(
`https://api.linkedin.com/v2/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(
`urn:li:organization:${id}`
)}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`,
diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts
index 0f479501..4aa1c9d5 100644
--- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts
@@ -14,7 +14,11 @@ import { PostPlug } from '@gitroom/helpers/decorators/post.plug';
import { LinkedinDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/linkedin.dto';
import imageToPDF from 'image-to-pdf';
import { Readable } from 'stream';
+import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
+@Rules(
+ 'LinkedIn can have maximum one attachment when selecting video, when choosing a carousel on LinkedIn minimum amount of attachment must be two, and only pictures, if uploading a video, LinkedIn can have only one attachment'
+)
export class LinkedinProvider extends SocialAbstract implements SocialProvider {
identifier = 'linkedin';
name = 'LinkedIn';
@@ -30,9 +34,12 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
'w_organization_social',
'r_organization_social',
];
+ override maxConcurrentJob = 2; // LinkedIn has professional posting limits
refreshWait = true;
editor = 'normal' as const;
-
+ maxLength() {
+ return 3000;
+ }
async refreshToken(refresh_token: string): Promise {
const {
access_token: accessToken,
@@ -54,7 +61,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
).json();
const { vanityName } = await (
- await this.fetch('https://api.linkedin.com/v2/me', {
+ await fetch('https://api.linkedin.com/v2/me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
@@ -66,7 +73,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
sub: id,
picture,
} = await (
- await this.fetch('https://api.linkedin.com/v2/userinfo', {
+ await fetch('https://api.linkedin.com/v2/userinfo', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
@@ -79,7 +86,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
refreshToken,
expiresIn: expires_in,
name,
- picture,
+ picture: picture || '',
username: vanityName,
};
}
@@ -122,7 +129,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
refresh_token: refreshToken,
scope,
} = await (
- await this.fetch('https://www.linkedin.com/oauth/v2/accessToken', {
+ await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -138,7 +145,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
sub: id,
picture,
} = await (
- await this.fetch('https://api.linkedin.com/v2/userinfo', {
+ await fetch('https://api.linkedin.com/v2/userinfo', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
@@ -146,7 +153,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
).json();
const { vanityName } = await (
- await this.fetch('https://api.linkedin.com/v2/me', {
+ await fetch('https://api.linkedin.com/v2/me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
@@ -174,7 +181,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
}
const { elements } = await (
- await this.fetch(
+ await fetch(
`https://api.linkedin.com/v2/organizations?q=vanityName&vanityName=${getCompanyVanity[1]}`,
{
method: 'GET',
@@ -253,20 +260,26 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
const etags = [];
for (let i = 0; i < picture.length; i += 1024 * 1024 * 2) {
- const upload = await this.fetch(sendUrlRequest, {
- method: 'PUT',
- headers: {
- 'X-Restli-Protocol-Version': '2.0.0',
- 'LinkedIn-Version': '202501',
- Authorization: `Bearer ${accessToken}`,
- ...(isVideo
- ? { 'Content-Type': 'application/octet-stream' }
- : isPdf
- ? { 'Content-Type': 'application/pdf' }
- : {}),
+ const upload = await this.fetch(
+ sendUrlRequest,
+ {
+ method: 'PUT',
+ headers: {
+ 'X-Restli-Protocol-Version': '2.0.0',
+ 'LinkedIn-Version': '202501',
+ Authorization: `Bearer ${accessToken}`,
+ ...(isVideo
+ ? { 'Content-Type': 'application/octet-stream' }
+ : isPdf
+ ? { 'Content-Type': 'application/pdf' }
+ : {}),
+ },
+ body: picture.slice(i, i + 1024 * 1024 * 2),
},
- body: picture.slice(i, i + 1024 * 1024 * 2),
- });
+ 'linkedin',
+ 0,
+ true
+ );
etags.push(upload.headers.get('etag'));
}
@@ -715,4 +728,34 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
},
});
}
+
+ override async mention(token: string, data: { query: string }) {
+ const { elements } = await (
+ await fetch(
+ `https://api.linkedin.com/v2/organizations?q=vanityName&vanityName=${encodeURIComponent(
+ data.query
+ )}&projection=(elements*(id,localizedName,logoV2(original~:playableStreams)))`,
+ {
+ headers: {
+ 'X-Restli-Protocol-Version': '2.0.0',
+ 'Content-Type': 'application/json',
+ 'LinkedIn-Version': '202504',
+ Authorization: `Bearer ${token}`,
+ },
+ }
+ )
+ ).json();
+
+ return elements.map((p: any) => ({
+ id: String(p.id),
+ label: p.localizedName,
+ image:
+ p.logoV2?.['original~']?.elements?.[0]?.identifiers?.[0]?.identifier ||
+ '',
+ }));
+ }
+
+ mentionFormat(idOrHandle: string, name: string) {
+ return `@[${name.replace('@', '')}](urn:li:organization:${idOrHandle})`;
+ }
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts
new file mode 100644
index 00000000..937bb5b5
--- /dev/null
+++ b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts
@@ -0,0 +1,277 @@
+import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { SocialAbstract } from '../social.abstract';
+import {
+ AuthTokenDetails,
+ PostDetails,
+ PostResponse,
+ SocialProvider,
+} from './social.integrations.interface';
+import dayjs from 'dayjs';
+import { Integration } from '@prisma/client';
+import { ListmonkDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/listmonk.dto';
+import { AuthService } from '@gitroom/helpers/auth/auth.service';
+import slugify from 'slugify';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
+
+export class ListmonkProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 100; // Bluesky has moderate rate limits
+ identifier = 'listmonk';
+ name = 'ListMonk';
+ isBetweenSteps = false;
+ scopes = [] as string[];
+ editor = 'html' as const;
+ dto = ListmonkDto;
+
+ maxLength() {
+ return 100000000;
+ }
+
+ async customFields() {
+ return [
+ {
+ key: 'url',
+ label: 'URL',
+ defaultValue: '',
+ validation: `/^(https?:\\/\\/)(?:\\S+(?::\\S*)?@)?(?:(?:localhost)|(?:\\d{1,3}(?:\\.\\d{1,3}){3})|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63})(?::\\d{2,5})?(?:\\/[^\\s?#]*)?(?:\\?[^\\s#]*)?(?:#[^\\s]*)?$/`,
+ type: 'text' as const,
+ },
+ {
+ key: 'username',
+ label: 'Username',
+ validation: `/^.+$/`,
+ type: 'text' as const,
+ },
+ {
+ key: 'password',
+ label: 'Password',
+ validation: `/^.{3,}$/`,
+ type: 'password' as const,
+ },
+ ];
+ }
+
+ async refreshToken(refreshToken: string): Promise {
+ return {
+ refreshToken: '',
+ expiresIn: 0,
+ accessToken: '',
+ id: '',
+ name: '',
+ picture: '',
+ username: '',
+ };
+ }
+
+ async generateAuthUrl() {
+ const state = makeId(6);
+ return {
+ url: '',
+ codeVerifier: makeId(10),
+ state,
+ };
+ }
+
+ async authenticate(params: {
+ code: string;
+ codeVerifier: string;
+ refresh?: string;
+ }) {
+ const body: { url: string; username: string; password: string } =
+ JSON.parse(Buffer.from(params.code, 'base64').toString());
+
+ console.log(body);
+ try {
+ const basic = Buffer.from(body.username + ':' + body.password).toString(
+ 'base64'
+ );
+
+ const { data } = await (
+ await this.fetch(body.url + '/api/settings', {
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ Authorization: 'Basic ' + basic,
+ },
+ })
+ ).json();
+
+ return {
+ refreshToken: basic,
+ expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
+ accessToken: basic,
+ id: Buffer.from(body.url).toString('base64'),
+ name: data['app.site_name'],
+ picture: data['app.logo_url'] || '',
+ username: data['app.site_name'],
+ };
+ } catch (e) {
+ console.log(e);
+ return 'Invalid credentials';
+ }
+ }
+
+ @Tool({ description: 'List of available lists', dataSchema: [] })
+ async list(
+ token: string,
+ data: any,
+ internalId: string,
+ integration: Integration
+ ) {
+ const body: { url: string; username: string; password: string } =
+ JSON.parse(
+ AuthService.fixedDecryption(integration.customInstanceDetails!)
+ );
+
+ const auth = Buffer.from(`${body.username}:${body.password}`).toString(
+ 'base64'
+ );
+
+ const postTypes = await (
+ await this.fetch(`${body.url}/api/lists`, {
+ headers: {
+ Authorization: `Basic ${auth}`,
+ },
+ })
+ ).json();
+
+ return postTypes.data.results.map((p: any) => ({ id: p.id, name: p.name }));
+ }
+
+ @Tool({ description: 'List of available templates', dataSchema: [] })
+ async templates(
+ token: string,
+ data: any,
+ internalId: string,
+ integration: Integration
+ ) {
+ const body: { url: string; username: string; password: string } =
+ JSON.parse(
+ AuthService.fixedDecryption(integration.customInstanceDetails!)
+ );
+
+ const auth = Buffer.from(`${body.username}:${body.password}`).toString(
+ 'base64'
+ );
+
+ const postTypes = await (
+ await this.fetch(`${body.url}/api/templates`, {
+ headers: {
+ Authorization: `Basic ${auth}`,
+ },
+ })
+ ).json();
+
+ return [
+ { id: 0, name: 'Default' },
+ ...postTypes.data.map((p: any) => ({ id: p.id, name: p.name })),
+ ];
+ }
+
+ async post(
+ id: string,
+ accessToken: string,
+ postDetails: PostDetails[],
+ integration: Integration
+ ): Promise {
+ const body: { url: string; username: string; password: string } =
+ JSON.parse(
+ AuthService.fixedDecryption(integration.customInstanceDetails!)
+ );
+
+ const auth = Buffer.from(`${body.username}:${body.password}`).toString(
+ 'base64'
+ );
+
+ const sendBody = `
+
+
+
+
+ ${postDetails[0].message}
+
+`;
+
+ const {
+ data: { uuid: postId, id: campaignId },
+ } = await (
+ await this.fetch(body.url + '/api/campaigns', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ Authorization: `Basic ${auth}`,
+ },
+ body: JSON.stringify({
+ name: slugify(postDetails[0].settings.subject, {
+ lower: true,
+ strict: true,
+ trim: true,
+ }),
+ type: 'regular',
+ content_type: 'html',
+ subject: postDetails[0].settings.subject,
+ lists: [+postDetails[0].settings.list],
+ body: sendBody,
+ ...(+postDetails?.[0]?.settings?.template
+ ? { template_id: +postDetails[0].settings.template }
+ : {}),
+ }),
+ })
+ ).json();
+
+ await this.fetch(body.url + `/api/campaigns/${campaignId}/status`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ Authorization: `Basic ${auth}`,
+ },
+ body: JSON.stringify({
+ status: 'running',
+ }),
+ });
+
+ return [
+ {
+ id: postDetails[0].id,
+ status: 'completed',
+ releaseURL: `${body.url}/api/campaigns/${campaignId}/preview`,
+ postId,
+ },
+ ];
+ }
+}
diff --git a/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts
index d38c1b2b..f1c078a6 100644
--- a/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts
@@ -9,6 +9,7 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
export class MastodonCustomProvider extends MastodonProvider {
override identifier = 'mastodon-custom';
override name = 'M. Instance';
+ override maxConcurrentJob = 5; // Custom Mastodon instances typically have generous limits
editor = 'normal' as const;
async externalUrl(url: string) {
diff --git a/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts
index b9f1da7e..15f1b934 100644
--- a/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts
@@ -9,11 +9,15 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab
import dayjs from 'dayjs';
export class MastodonProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 5; // Mastodon instances typically have generous limits
identifier = 'mastodon';
name = 'Mastodon';
isBetweenSteps = false;
scopes = ['write:statuses', 'profile', 'write:media'];
editor = 'normal' as const;
+ maxLength() {
+ return 500;
+ }
async refreshToken(refreshToken: string): Promise {
return {
@@ -90,7 +94,7 @@ export class MastodonProvider extends SocialAbstract implements SocialProvider {
accessToken: tokenInformation.access_token,
refreshToken: 'null',
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
- picture: personalInformation.avatar,
+ picture: personalInformation?.avatar || '',
username: personalInformation.username,
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts b/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts
index cf7d05ee..e5b8752b 100644
--- a/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts
@@ -8,13 +8,20 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+import { MediumSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/medium.settings.dto';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
export class MediumProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 3; // Medium has lenient publishing limits
identifier = 'medium';
name = 'Medium';
isBetweenSteps = false;
scopes = [] as string[];
editor = 'markdown' as const;
+ dto = MediumSettingsDto;
+ maxLength() {
+ return 100000;
+ }
async generateAuthUrl() {
const state = makeId(6);
@@ -71,7 +78,7 @@ export class MediumProvider extends SocialAbstract implements SocialProvider {
accessToken: body.apiKey,
id,
name,
- picture: imageUrl,
+ picture: imageUrl || '',
username,
};
} catch (err) {
@@ -79,6 +86,7 @@ export class MediumProvider extends SocialAbstract implements SocialProvider {
}
}
+ @Tool({ description: 'List of publications', dataSchema: [] })
async publications(accessToken: string, _: any, id: string) {
const { data } = await (
await fetch(`https://api.medium.com/v1/users/${id}/publications`, {
diff --git a/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts
index e165a31f..3797117c 100644
--- a/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts
@@ -24,12 +24,17 @@ const list = [
];
export class NostrProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 5; // Nostr relays typically have generous limits
identifier = 'nostr';
name = 'Nostr';
isBetweenSteps = false;
scopes = [] as string[];
editor = 'normal' as const;
+ maxLength() {
+ return 100000;
+ }
+
async customFields() {
return [
{
@@ -146,7 +151,7 @@ export class NostrProvider extends SocialAbstract implements SocialProvider {
accessToken: AuthService.signJWT({ password: body.password }),
refreshToken: '',
expiresIn: dayjs().add(200, 'year').unix() - dayjs().unix(),
- picture: user.picture,
+ picture: user?.picture || '',
username: user.name || 'nousername',
};
} catch (e) {
diff --git a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts
index ef9814ba..f9779010 100644
--- a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts
@@ -12,7 +12,12 @@ import FormData from 'form-data';
import { timer } from '@gitroom/helpers/utils/timer';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
+import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
+@Rules(
+ 'Pinterest requires at least one media, if posting a video, you must have two attachment, one for video, one for the cover picture, When posting a video, there can be only one'
+)
export class PinterestProvider
extends SocialAbstract
implements SocialProvider
@@ -27,6 +32,12 @@ export class PinterestProvider
'pins:write',
'user_accounts:read',
];
+ override maxConcurrentJob = 3; // Pinterest has more lenient rate limits
+ maxLength() {
+ return 500;
+ }
+
+ dto = PinterestSettingsDto;
editor = 'normal' as const;
@@ -36,7 +47,6 @@ export class PinterestProvider
value: string;
}
| undefined {
-
if (body.indexOf('cover_image_url or cover_image_content_type') > -1) {
return {
type: 'bad-body' as const,
@@ -50,7 +60,7 @@ export class PinterestProvider
async refreshToken(refreshToken: string): Promise {
const { access_token, expires_in } = await (
- await this.fetch('https://api.pinterest.com/v5/oauth/token', {
+ await fetch('https://api.pinterest.com/v5/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -68,7 +78,7 @@ export class PinterestProvider
).json();
const { id, profile_image, username } = await (
- await this.fetch('https://api.pinterest.com/v5/user_account', {
+ await fetch('https://api.pinterest.com/v5/user_account', {
method: 'GET',
headers: {
Authorization: `Bearer ${access_token}`,
@@ -82,7 +92,7 @@ export class PinterestProvider
accessToken: access_token,
refreshToken: refreshToken,
expiresIn: expires_in,
- picture: profile_image,
+ picture: profile_image || '',
username,
};
}
@@ -108,7 +118,7 @@ export class PinterestProvider
refresh: string;
}) {
const { access_token, refresh_token, expires_in, scope } = await (
- await this.fetch('https://api.pinterest.com/v5/oauth/token', {
+ await fetch('https://api.pinterest.com/v5/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -127,7 +137,7 @@ export class PinterestProvider
this.checkScopes(this.scopes, scope);
const { id, profile_image, username } = await (
- await this.fetch('https://api.pinterest.com/v5/user_account', {
+ await fetch('https://api.pinterest.com/v5/user_account', {
method: 'GET',
headers: {
Authorization: `Bearer ${access_token}`,
@@ -146,9 +156,10 @@ export class PinterestProvider
};
}
+ @Tool({ description: 'List of boards', dataSchema: [] })
async boards(accessToken: string) {
const { items } = await (
- await this.fetch('https://api.pinterest.com/v5/boards', {
+ await fetch('https://api.pinterest.com/v5/boards', {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -211,15 +222,21 @@ export class PinterestProvider
let statusCode = '';
while (statusCode !== 'succeeded') {
const mediafile = await (
- await this.fetch('https://api.pinterest.com/v5/media/' + media_id, {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${accessToken}`,
+ await this.fetch(
+ 'https://api.pinterest.com/v5/media/' + media_id,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
},
- })
+ '',
+ 0,
+ true
+ )
).json();
- await timer(3000);
+ await timer(30000);
statusCode = mediafile.status;
}
@@ -289,7 +306,7 @@ export class PinterestProvider
const {
all: { daily_metrics },
} = await (
- await this.fetch(
+ await fetch(
`https://api.pinterest.com/v5/user_account/analytics?start_date=${since}&end_date=${until}`,
{
method: 'GET',
diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts
index 1dfd9aaf..8a3eb0e0 100644
--- a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts
@@ -9,13 +9,26 @@ import { RedditSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/provider
import { timer } from '@gitroom/helpers/utils/timer';
import { groupBy } from 'lodash';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
+import { lookup } from 'mime-types';
+import axios from 'axios';
+import WebSocket from 'ws';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
+
+// @ts-ignore
+global.WebSocket = WebSocket;
export class RedditProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 1; // Reddit has strict rate limits (1 request per second)
identifier = 'reddit';
name = 'Reddit';
isBetweenSteps = false;
scopes = ['read', 'identity', 'submit', 'flair'];
editor = 'normal' as const;
+ dto = RedditSettingsDto;
+
+ maxLength() {
+ return 10000;
+ }
async refreshToken(refreshToken: string): Promise {
const {
@@ -52,7 +65,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
accessToken,
refreshToken: newRefreshToken,
expiresIn,
- picture: icon_img.split('?')[0],
+ picture: icon_img?.split?.('?')?.[0] || '',
username: name,
};
}
@@ -111,11 +124,60 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
accessToken,
refreshToken,
expiresIn,
- picture: icon_img.split('?')[0],
+ picture: icon_img?.split?.('?')?.[0] || '',
username: name,
};
}
+ private async uploadFileToReddit(accessToken: string, path: string) {
+ const mimeType = lookup(path);
+ const formData = new FormData();
+ formData.append('filepath', path.split('/').pop());
+ formData.append('mimetype', mimeType || 'application/octet-stream');
+
+ const {
+ args: { action, fields },
+ } = await (
+ await this.fetch(
+ 'https://oauth.reddit.com/api/media/asset',
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: formData,
+ },
+ 'reddit',
+ 0,
+ true
+ )
+ ).json();
+
+ const { data } = await axios.get(path, {
+ responseType: 'arraybuffer',
+ });
+
+ const upload = (fields as { name: string; value: string }[]).reduce(
+ (acc, value) => {
+ acc.append(value.name, value.value);
+ return acc;
+ },
+ new FormData()
+ );
+
+ upload.append(
+ 'file',
+ new Blob([Buffer.from(data)], { type: mimeType as string })
+ );
+
+ const d = await fetch('https:' + action, {
+ method: 'POST',
+ body: upload,
+ });
+
+ return [...(await d.text()).matchAll(/(.*?)<\/Location>/g)][0][1];
+ }
+
async post(
id: string,
accessToken: string,
@@ -130,7 +192,9 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
title: firstPostSettings.value.title || '',
kind:
firstPostSettings.value.type === 'media'
- ? 'image'
+ ? post.media[0].path.indexOf('mp4') > -1
+ ? 'video'
+ : 'image'
: firstPostSettings.value.type,
...(firstPostSettings.value.flair
? { flair_id: firstPostSettings.value.flair.id }
@@ -142,22 +206,25 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
: {}),
...(firstPostSettings.value.type === 'media'
? {
- url: `${
- firstPostSettings.value.media[0].path.indexOf('http') === -1
- ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/uploads`
- : ``
- }${firstPostSettings.value.media[0].path}`,
+ url: await this.uploadFileToReddit(
+ accessToken,
+ post.media[0].path
+ ),
+ ...(post.media[0].path.indexOf('mp4') > -1
+ ? {
+ video_poster_url: await this.uploadFileToReddit(
+ accessToken,
+ post.media[0].thumbnail
+ ),
+ }
+ : {}),
}
: {}),
text: post.message,
sr: firstPostSettings.value.subreddit,
};
- const {
- json: {
- data: { id, name, url },
- },
- } = await (
+ const all = await (
await this.fetch('https://oauth.reddit.com/api/submit', {
method: 'POST',
headers: {
@@ -168,6 +235,38 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
})
).json();
+ const { id, name, url } = await new Promise<{
+ id: string;
+ name: string;
+ url: string;
+ }>((res) => {
+ if (all?.json?.data?.id) {
+ res(all.json.data);
+ }
+
+ const ws = new WebSocket(all.json.data.websocket_url);
+ ws.on('message', (data: any) => {
+ setTimeout(() => {
+ res({ id: '', name: '', url: '' });
+ ws.close();
+ }, 30_000);
+ try {
+ const parsedData = JSON.parse(data.toString());
+ if (parsedData?.payload?.redirect) {
+ const onlyId = parsedData?.payload?.redirect.replace(
+ /https:\/\/www\.reddit\.com\/r\/.*?\/comments\/(.*?)\/.*/g,
+ '$1'
+ );
+ res({
+ id: onlyId,
+ name: `t3_${onlyId}`,
+ url: parsedData?.payload?.redirect,
+ });
+ }
+ } catch (err) {}
+ });
+ });
+
valueArray.push({
postId: id,
releaseURL: url,
@@ -201,8 +300,6 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
})
).json();
- // console.log(JSON.stringify(allTop, null, 2), JSON.stringify(allJson, null, 2), JSON.stringify(allData, null, 2));
-
valueArray.push({
postId: commentId,
releaseURL: 'https://www.reddit.com' + permalink,
@@ -228,6 +325,16 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
}));
}
+ @Tool({
+ description: 'Get list of subreddits with information',
+ dataSchema: [
+ {
+ key: 'word',
+ type: 'string',
+ description: 'Search subreddit by string',
+ },
+ ],
+ })
async subreddits(accessToken: string, data: any) {
const {
data: { children },
@@ -240,7 +347,10 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
- }
+ },
+ 'reddit',
+ 0,
+ false
)
).json();
@@ -266,27 +376,43 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
permissions.push('link');
}
- // if (submissionType === 'any' || allow_images) {
- // permissions.push('media');
- // }
+ if (allow_images) {
+ permissions.push('media');
+ }
return permissions;
}
+ @Tool({
+ description: 'Get list of flairs and restrictions for a subreddit',
+ dataSchema: [
+ {
+ key: 'subreddit',
+ type: 'string',
+ description: 'Search flairs and restrictions by subreddit key should be "/r/[name]"',
+ },
+ ],
+ })
async restrictions(accessToken: string, data: { subreddit: string }) {
const {
- data: { submission_type, allow_images },
+ data: { submission_type, allow_images, ...all2 },
} = await (
- await this.fetch(`https://oauth.reddit.com/${data.subreddit}/about`, {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${accessToken}`,
- 'Content-Type': 'application/x-www-form-urlencoded',
+ await this.fetch(
+ `https://oauth.reddit.com/${data.subreddit}/about`,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
},
- })
+ 'reddit',
+ 0,
+ false
+ )
).json();
- const { is_flair_required } = await (
+ const { is_flair_required, ...all } = await (
await this.fetch(
`https://oauth.reddit.com/api/v1/${
data.subreddit.split('/r/')[1]
@@ -297,7 +423,10 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
- }
+ },
+ 'reddit',
+ 0,
+ false
)
).json();
@@ -314,7 +443,10 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
- }
+ },
+ 'reddit',
+ 0,
+ false
)
).json();
@@ -328,7 +460,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
return {
subreddit: data.subreddit,
allow: this.getPermissions(submission_type, allow_images),
- is_flair_required,
+ is_flair_required: is_flair_required && newData.length > 0,
flairs:
newData?.map?.((p: any) => ({
id: p.id,
diff --git a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts
index ad45c12a..c29b9b3c 100644
--- a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts
@@ -8,8 +8,11 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
+import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
export class SlackProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 3; // Slack has moderate API limits
identifier = 'slack';
name = 'Slack';
isBetweenSteps = false;
@@ -22,6 +25,12 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
'channels:join',
'chat:write.customize',
];
+ dto = SlackDto;
+
+ maxLength() {
+ return 400000;
+ }
+
async refreshToken(refreshToken: string): Promise {
return {
refreshToken: '',
@@ -94,11 +103,15 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
accessToken: access_token,
refreshToken: 'null',
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
- picture: user.profile.image_original,
+ picture: user?.profile?.image_original || '',
username: user.name,
};
}
+ @Tool({
+ description: 'Get list of channels',
+ dataSchema: [],
+ })
async channels(accessToken: string, params: any, id: string) {
const list = await (
await fetch(
diff --git a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts
index f666752b..9b623965 100644
--- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts
@@ -113,6 +113,8 @@ export interface SocialProvider
identifier: string;
refreshWait?: boolean;
convertToJPEG?: boolean;
+ dto?: any;
+ maxLength: (additionalSettings?: any) => number;
isWeb3?: boolean;
editor: 'normal' | 'markdown' | 'html';
customFields?: () => Promise<
@@ -132,4 +134,8 @@ export interface SocialProvider
externalUrl?: (
url: string
) => Promise<{ client_id: string; client_secret: string }>;
+ mention?: (
+ token: string, data: { query: string }, id: string, integration: Integration
+ ) => Promise<{ id: string; label: string; image: string, doNotCache?: boolean }[] | {none: true}>;
+ mentionFormat?(idOrHandle: string, name: string): string;
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts
index ff8ad4c4..ba07ba9b 100644
--- a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts
@@ -11,6 +11,7 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab
import mime from 'mime';
import TelegramBot from 'node-telegram-bot-api';
import { Integration } from '@prisma/client';
+import striptags from 'striptags';
const telegramBot = new TelegramBot(process.env.TELEGRAM_TOKEN!);
// Added to support local storage posting
@@ -18,12 +19,16 @@ const frontendURL = process.env.FRONTEND_URL || 'http://localhost:5000';
const mediaStorage = process.env.STORAGE_PROVIDER || 'local';
export class TelegramProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 3; // Telegram has moderate bot API limits
identifier = 'telegram';
name = 'Telegram';
isBetweenSteps = false;
isWeb3 = true;
scopes = [] as string[];
- editor = 'markdown' as const;
+ editor = 'html' as const;
+ maxLength() {
+ return 4096;
+ }
async refreshToken(refresh_token: string): Promise {
return {
@@ -69,7 +74,7 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider {
accessToken: String(chat.id),
refreshToken: '',
expiresIn: dayjs().add(200, 'year').unix() - dayjs().unix(),
- picture: photo,
+ picture: photo || '',
username: chat.username!,
};
}
@@ -145,7 +150,12 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider {
for (const message of postDetails) {
let messageId: number | null = null;
const mediaFiles = message.media || [];
- const text = message.message || '';
+ const text = striptags(message.message || '', ['u', 'strong', 'p'])
+ .replace(//g, '')
+ .replace(/<\/strong>/g, '')
+ .replace(/(.*?)<\/p>/g, '$1\n');
+
+ console.log(text);
// check if media is local to modify url
const processedMedia = mediaFiles.map((media) => {
let mediaUrl = media.path;
@@ -176,7 +186,9 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider {
});
// if there's no media, bot sends a text message only
if (processedMedia.length === 0) {
- const response = await telegramBot.sendMessage(accessToken, text);
+ 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
@@ -187,20 +199,20 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider {
? await telegramBot.sendVideo(
accessToken,
media.media,
- { caption: text, parse_mode: 'Markdown' },
+ { caption: text, parse_mode: 'HTML' },
media.fileOptions
)
: media.type === 'photo'
? await telegramBot.sendPhoto(
accessToken,
media.media,
- { caption: text, parse_mode: 'Markdown' },
+ { caption: text, parse_mode: 'HTML' },
media.fileOptions
)
: await telegramBot.sendDocument(
accessToken,
media.media,
- { caption: text, parse_mode: 'Markdown' },
+ { caption: text, parse_mode: 'HTML' },
media.fileOptions
);
messageId = response.message_id;
@@ -213,7 +225,7 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider {
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: 'Markdown'
+ parse_mode: 'HTML',
}));
const response = await telegramBot.sendMediaGroup(
diff --git a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts
index 37ed96fa..3a39b13e 100644
--- a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts
@@ -23,9 +23,14 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
'threads_content_publish',
'threads_manage_replies',
'threads_manage_insights',
+ // 'threads_profile_discovery',
];
+ override maxConcurrentJob = 2; // Threads has moderate rate limits
editor = 'normal' as const;
+ maxLength() {
+ return 500;
+ }
async refreshToken(refresh_token: string): Promise {
const { access_token } = await (
@@ -34,14 +39,9 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
)
).json();
- const {
- id,
- name,
- username,
- picture: {
- data: { url },
- },
- } = await this.fetchPageInformation(access_token);
+ const { id, name, username, picture } = await this.fetchPageInformation(
+ access_token
+ );
return {
id,
@@ -49,7 +49,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
accessToken: access_token,
refreshToken: access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
- picture: url,
+ picture: picture?.data?.url || '',
username: '',
};
}
@@ -105,14 +105,9 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
)
).json();
- const {
- id,
- name,
- username,
- picture: {
- data: { url },
- },
- } = await this.fetchPageInformation(access_token);
+ const { id, name, username, picture } = await this.fetchPageInformation(
+ access_token
+ );
return {
id,
@@ -120,7 +115,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
accessToken: access_token,
refreshToken: access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
- picture: url,
+ picture: picture?.data?.url || '',
username: username,
};
}
@@ -413,8 +408,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
{
id: makeId(10),
media: [],
- message:
- postDetails?.[0]?.settings?.thread_finisher,
+ message: postDetails?.[0]?.settings?.thread_finisher,
settings: {},
},
lastReplyId,
@@ -526,4 +520,29 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
return false;
}
+
+ // override async mention(
+ // token: string,
+ // data: { query: string },
+ // id: string,
+ // integration: Integration
+ // ) {
+ // const p = await (
+ // await fetch(
+ // `https://graph.threads.net/v1.0/profile_lookup?username=${data.query}&access_token=${integration.token}`
+ // )
+ // ).json();
+ //
+ // return [
+ // {
+ // id: String(p.id),
+ // label: p.name,
+ // image: p.profile_picture_url,
+ // },
+ // ];
+ // }
+ //
+ // mentionFormat(idOrHandle: string, name: string) {
+ // return `@${idOrHandle}`;
+ // }
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts
index 6a37dc10..8682d1aa 100644
--- a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts
@@ -12,7 +12,12 @@ import {
import { TikTokDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tiktok.dto';
import { timer } from '@gitroom/helpers/utils/timer';
import { Integration } from '@prisma/client';
+import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
+import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
+@Rules(
+ 'TikTok can have one video or one picture or multiple pictures, it cannot be without an attachment'
+)
export class TiktokProvider extends SocialAbstract implements SocialProvider {
identifier = 'tiktok';
name = 'Tiktok';
@@ -24,8 +29,12 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
'video.upload',
'user.info.profile',
];
-
+ override maxConcurrentJob = 1; // TikTok has strict video upload limits
+ dto = TikTokDto;
editor = 'normal' as const;
+ maxLength() {
+ return 2000;
+ }
override handleErrors(body: string):
| {
@@ -249,7 +258,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
accessToken: access_token,
id: open_id.replace(/-/g, ''),
name: display_name,
- picture: avatar_url,
+ picture: avatar_url || '',
username: username,
};
}
@@ -295,7 +304,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
};
const { access_token, refresh_token, scope } = await (
- await this.fetch('https://open.tiktokapis.com/v2/oauth/token/', {
+ await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
@@ -338,7 +347,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
const {
data: { max_video_post_duration_sec },
} = await (
- await this.fetch(
+ await fetch(
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/',
{
method: 'POST',
@@ -374,12 +383,22 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
body: JSON.stringify({
publish_id: publishId,
}),
- }
+ },
+ '',
+ 0,
+ true
)
).json();
const { status, publicaly_available_post_id } = post.data;
+ if (status === 'SEND_TO_USER_INBOX') {
+ return {
+ url: 'https://www.tiktok.com/tiktokstudio/content?tab=post',
+ id: Math.floor(Math.random() * 1000000 + 100000),
+ };
+ }
+
if (status === 'PUBLISH_COMPLETE') {
return {
url: !publicaly_available_post_id
@@ -398,11 +417,11 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
'titok-error-upload',
JSON.stringify(post),
Buffer.from(JSON.stringify(post)),
- handleError?.value || '',
+ handleError?.value || ''
);
}
- await timer(3000);
+ await timer(10000);
}
}
@@ -426,7 +445,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
integration: Integration
): Promise {
const [firstPost] = postDetails;
-
+ console.log('hello');
const isPhoto = (firstPost?.media?.[0]?.path?.indexOf('mp4') || -1) === -1;
const {
data: { publish_id },
@@ -461,6 +480,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
disable_duet: !firstPost.settings.duet || false,
disable_comment: !firstPost.settings.comment || false,
disable_stitch: !firstPost.settings.stitch || false,
+ is_aigc: firstPost.settings.video_made_with_ai || false,
brand_content_toggle:
firstPost.settings.brand_content_toggle || false,
brand_organic_toggle:
@@ -494,7 +514,11 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
photo_cover_index: 0,
photo_images: firstPost.media?.map((p) => p.path),
},
- post_mode: firstPost?.settings?.content_posting_method === 'DIRECT_POST' ? 'DIRECT_POST' : 'MEDIA_UPLOAD',
+ post_mode:
+ firstPost?.settings?.content_posting_method ===
+ 'DIRECT_POST'
+ ? 'DIRECT_POST'
+ : 'MEDIA_UPLOAD',
media_type: 'PHOTO',
}),
}),
diff --git a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts
index e5b45abb..adbc8600 100644
--- a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts
@@ -13,6 +13,7 @@ import FormDataNew from 'form-data';
import mime from 'mime-types';
export class VkProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 2; // VK has moderate API limits
identifier = 'vk';
name = 'VK';
isBetweenSteps = false;
@@ -27,6 +28,9 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
];
editor = 'normal' as const;
+ maxLength() {
+ return 2048;
+ }
async refreshToken(refresh: string): Promise {
const [oldRefreshToken, device_id] = refresh.split('&&&&');
@@ -64,7 +68,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
accessToken: access_token,
refreshToken: refresh_token + '&&&&' + device_id,
expiresIn: dayjs().add(expires_in, 'seconds').unix() - dayjs().unix(),
- picture: avatar,
+ picture: avatar || '',
username: first_name.toLowerCase(),
};
}
@@ -149,7 +153,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
accessToken: access_token,
refreshToken: refresh_token + '&&&&' + device_id,
expiresIn: dayjs().add(expires_in, 'seconds').unix() - dayjs().unix(),
- picture: avatar,
+ picture: avatar || '',
username: first_name.toLowerCase(),
};
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts
index 31b6ddd3..88e9dab9 100644
--- a/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts
@@ -10,8 +10,9 @@ import { Integration } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { WordpressDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/wordpress.dto';
import slugify from 'slugify';
-import FormData from 'form-data';
+// import FormData from 'form-data';
import axios from 'axios';
+import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
export class WordpressProvider
extends SocialAbstract
@@ -22,6 +23,11 @@ export class WordpressProvider
isBetweenSteps = false;
editor = 'html' as const;
scopes = [] as string[];
+ override maxConcurrentJob = 5; // WordPress self-hosted typically has generous limits
+ dto = WordpressDto;
+ maxLength() {
+ return 100000;
+ }
async generateAuthUrl() {
const state = makeId(6);
@@ -114,6 +120,10 @@ export class WordpressProvider
}
}
+ @Tool({
+ description: 'Get list of post types',
+ dataSchema: [],
+ })
async postTypes(token: string) {
const body = JSON.parse(Buffer.from(token, 'base64').toString()) as {
domain: string;
@@ -169,35 +179,30 @@ export class WordpressProvider
let mediaId = '';
if (postDetails?.[0]?.settings?.main_image?.path) {
- console.log('Uploading image to WordPress', postDetails[0].settings.main_image.path);
- const imageData = await axios.get(postDetails[0].settings.main_image.path, {
- responseType: 'stream',
- });
-
- const form = new FormData();
- form.append('file', imageData.data, {
- filename: postDetails[0].settings.main_image.path.split('/').pop(), // You can customize the filename
- contentType: imageData.headers['content-type'],
- });
- if (postDetails[0].settings.main_image?.alt) {
- form.append('alt_text', postDetails[0].settings.main_image.alt);
- }
-
- const mediaResponse = await axios.post(
- `${body.domain}/wp-json/wp/v2/media`,
- {
- method: 'POST',
- body: form,
- },
- {
- headers: {
- Authorization: `Basic ${auth}`,
- 'Content-Type': 'application/json',
- },
- }
+ console.log(
+ 'Uploading image to WordPress',
+ postDetails[0].settings.main_image.path
);
- mediaId = mediaResponse.data.id;
+ const blob = await this.fetch(
+ postDetails[0].settings.main_image.path
+ ).then((r) => r.blob());
+
+ const mediaResponse = await (
+ await this.fetch(`${body.domain}/wp-json/wp/v2/media`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Basic ${auth}`,
+ 'Content-Disposition': `attachment; filename="${postDetails[0].settings.main_image.path
+ .split('/')
+ .pop()}"`,
+ 'Content-Type': blob.type,
+ },
+ body: blob,
+ })
+ ).json();
+
+ mediaId = mediaResponse.id;
}
const submit = await (
diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts
index 574c648e..fe2c215a 100644
--- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts
@@ -17,16 +17,66 @@ import { PostPlug } from '@gitroom/helpers/decorators/post.plug';
import dayjs from 'dayjs';
import { uniqBy } from 'lodash';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
+import { XDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/x.dto';
+import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
+@Rules(
+ 'X can have maximum 4 pictures, or maximum one video, it can also be without attachments'
+)
export class XProvider extends SocialAbstract implements SocialProvider {
identifier = 'x';
name = 'X';
isBetweenSteps = false;
scopes = [] as string[];
+ override maxConcurrentJob = 1; // X has strict rate limits (300 posts per 3 hours)
toolTip =
'You will be logged in into your current account, if you would like a different account, change it first on X';
editor = 'normal' as const;
+ dto = XDto;
+
+ maxLength(isTwitterPremium: boolean) {
+ return isTwitterPremium ? 4000 : 200;
+ }
+
+ override handleErrors(body: string):
+ | {
+ type: 'refresh-token' | 'bad-body';
+ value: string;
+ }
+ | undefined {
+ if (body.includes('usage-capped')) {
+ return {
+ type: 'refresh-token',
+ value: 'Posting failed - capped reached. Please try again later',
+ };
+ }
+ if (body.includes('duplicate-rules')) {
+ return {
+ type: 'refresh-token',
+ value:
+ 'You have already posted this post, please wait before posting again',
+ };
+ }
+ if (body.includes('The Tweet contains an invalid URL.')) {
+ return {
+ type: 'bad-body',
+ value: 'The Tweet contains a URL that is not allowed on X',
+ };
+ }
+ if (
+ body.includes(
+ 'This user is not allowed to post a video longer than 2 minutes'
+ )
+ ) {
+ return {
+ type: 'bad-body',
+ value:
+ 'The video you are trying to post is longer than 2 minutes, which is not allowed for this account',
+ };
+ }
+ return undefined;
+ }
@Plug({
identifier: 'x-autoRepostPost',
@@ -228,7 +278,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
name,
refreshToken: '',
expiresIn: 999999999,
- picture: profile_image_url,
+ picture: profile_image_url || '',
username,
additionalSettings: [
{
@@ -277,22 +327,24 @@ export class XProvider extends SocialAbstract implements SocialProvider {
postDetails.flatMap((p) =>
p?.media?.flatMap(async (m) => {
return {
- id: await this.runInConcurrent(async () =>
- client.v1.uploadMedia(
- m.path.indexOf('mp4') > -1
- ? Buffer.from(await readOrFetch(m.path))
- : await sharp(await readOrFetch(m.path), {
- animated: lookup(m.path) === 'image/gif',
- })
- .resize({
- width: 1000,
+ id: await this.runInConcurrent(
+ async () =>
+ client.v1.uploadMedia(
+ m.path.indexOf('mp4') > -1
+ ? Buffer.from(await readOrFetch(m.path))
+ : await sharp(await readOrFetch(m.path), {
+ animated: lookup(m.path) === 'image/gif',
})
- .gif()
- .toBuffer(),
- {
- mimeType: lookup(m.path) || '',
- }
- )
+ .resize({
+ width: 1000,
+ })
+ .gif()
+ .toBuffer(),
+ {
+ mimeType: lookup(m.path) || '',
+ }
+ ),
+ true
),
postId: p.id,
};
@@ -315,7 +367,10 @@ export class XProvider extends SocialAbstract implements SocialProvider {
const media_ids = (uploadAll[post.id] || []).filter((f) => f);
// @ts-ignore
- const { data }: { data: { id: string } } = await this.runInConcurrent( async () => client.v2.tweet({
+ const { data }: { data: { id: string } } = await this.runInConcurrent(
+ async () =>
+ // @ts-ignore
+ client.v2.tweet({
...(!postDetails?.[0]?.settings?.who_can_reply_post ||
postDetails?.[0]?.settings?.who_can_reply_post === 'everyone'
? {}
@@ -350,7 +405,11 @@ export class XProvider extends SocialAbstract implements SocialProvider {
await this.runInConcurrent(async () =>
client.v2.tweet({
text:
- postDetails?.[0]?.settings?.thread_finisher! +
+ stripHtmlValidation(
+ 'normal',
+ postDetails?.[0]?.settings?.thread_finisher!,
+ true
+ ) +
'\n' +
ids[0].releaseURL,
reply: { in_reply_to_tweet_id: ids[ids.length - 1].postId },
@@ -434,7 +493,6 @@ export class XProvider extends SocialAbstract implements SocialProvider {
return [];
}
- console.log(tweets.map((p) => p.id));
const data = await client.v2.tweets(
tweets.map((p) => p.id),
{
@@ -470,9 +528,6 @@ export class XProvider extends SocialAbstract implements SocialProvider {
}
);
- console.log(metrics);
- console.log(JSON.stringify(data, null, 2));
-
return Object.entries(metrics).map(([key, value]) => ({
label: key.replace('_count', '').replace('_', ' ').toUpperCase(),
percentageChange: 5,
@@ -492,4 +547,39 @@ export class XProvider extends SocialAbstract implements SocialProvider {
}
return [];
}
+
+ override async mention(token: string, d: { query: string }) {
+ const [accessTokenSplit, accessSecretSplit] = token.split(':');
+ const client = new TwitterApi({
+ appKey: process.env.X_API_KEY!,
+ appSecret: process.env.X_API_SECRET!,
+ accessToken: accessTokenSplit,
+ accessSecret: accessSecretSplit,
+ });
+
+ try {
+ const data = await client.v2.userByUsername(d.query, {
+ 'user.fields': ['username', 'name', 'profile_image_url'],
+ });
+
+ if (!data?.data?.username) {
+ return [];
+ }
+
+ return [
+ {
+ id: data.data.username,
+ image: data.data.profile_image_url,
+ label: data.data.name,
+ },
+ ];
+ } catch (err) {
+ console.log(err);
+ }
+ return [];
+ }
+
+ mentionFormat(idOrHandle: string, name: string) {
+ return `@${idOrHandle}`;
+ }
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts
index 4bc8c0ef..36bffc53 100644
--- a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts
@@ -18,6 +18,7 @@ import * as process from 'node:process';
import dayjs from 'dayjs';
import { GaxiosResponse } from 'gaxios/build/src/common';
import Schema$Video = youtube_v3.Schema$Video;
+import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
const clientAndYoutube = () => {
const client = new google.auth.OAuth2({
@@ -47,10 +48,13 @@ const clientAndYoutube = () => {
return { client, youtube, oauth2, youtubeAnalytics };
};
+@Rules('YouTube must have on video attachment, it cannot be empty')
export class YoutubeProvider extends SocialAbstract implements SocialProvider {
+ override maxConcurrentJob = 1; // YouTube has strict upload quotas
identifier = 'youtube';
name = 'YouTube';
isBetweenSteps = false;
+ dto = YoutubeSettingsDto;
scopes = [
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email',
@@ -63,6 +67,58 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
];
editor = 'normal' as const;
+ maxLength() {
+ return 5000;
+ }
+
+ override handleErrors(body: string):
+ | {
+ type: 'refresh-token' | 'bad-body';
+ value: string;
+ }
+ | undefined {
+ if (body.includes('invalidTitle')) {
+ return {
+ type: 'bad-body',
+ value:
+ 'We have uploaded your video but we could not set the title. Title is too long.',
+ };
+ }
+
+ if (body.includes('failedPrecondition')) {
+ return {
+ type: 'bad-body',
+ value:
+ 'We have uploaded your video but we could not set the thumbnail. Thumbnail size is too large.',
+ };
+ }
+
+ if (body.includes('uploadLimitExceeded')) {
+ return {
+ type: 'bad-body',
+ value:
+ 'You have reached your daily upload limit, please try again tomorrow.',
+ };
+ }
+
+ if (body.includes('youtubeSignupRequired')) {
+ return {
+ type: 'bad-body',
+ value:
+ 'You have to link your youtube account to your google account first.',
+ };
+ }
+
+ if (body.includes('youtube.thumbnail')) {
+ return {
+ type: 'bad-body',
+ value:
+ 'Your account is not verified, we have uploaded your video but we could not set the thumbnail. Please verify your account and try again.',
+ };
+ }
+
+ return undefined;
+ }
async refreshToken(refresh_token: string): Promise {
const { client, oauth2 } = clientAndYoutube();
@@ -82,7 +138,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
refreshToken: credentials.refresh_token!,
id: data.id!,
name: data.name!,
- picture: data.picture!,
+ picture: data?.picture || '',
username: '',
};
}
@@ -128,7 +184,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
refreshToken: tokens.refresh_token!,
id: data.id!,
name: data.name!,
- picture: data.picture!,
+ picture: data?.picture || '',
username: '',
};
}
@@ -152,9 +208,8 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
responseType: 'stream',
});
- let all: GaxiosResponse;
- try {
- all = await this.runInConcurrent(async () =>
+ const all: GaxiosResponse = await this.runInConcurrent(
+ async () =>
youtubeClient.videos.insert({
part: ['id', 'snippet', 'status'],
notifySubscribers: true,
@@ -168,82 +223,32 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
},
status: {
privacyStatus: settings.type,
+ selfDeclaredMadeForKids:
+ settings.selfDeclaredMadeForKids === 'yes',
},
},
media: {
body: response.data,
},
- })
- );
- } catch (err: any) {
- if (
- err.response?.data?.error?.errors?.[0]?.reason === 'failedPrecondition'
- ) {
- throw new BadBody(
- 'youtube',
- JSON.stringify(err.response.data),
- JSON.stringify(err.response.data),
- 'We have uploaded your video but we could not set the thumbnail. Thumbnail size is too large.'
- );
- }
- if (
- err.response?.data?.error?.errors?.[0]?.reason === 'uploadLimitExceeded'
- ) {
- throw new BadBody(
- 'youtube',
- JSON.stringify(err.response.data),
- JSON.stringify(err.response.data),
- 'You have reached your daily upload limit, please try again tomorrow.'
- );
- }
- if (
- err.response?.data?.error?.errors?.[0]?.reason ===
- 'youtubeSignupRequired'
- ) {
- throw new BadBody(
- 'youtube',
- JSON.stringify(err.response.data),
- JSON.stringify(err.response.data),
- 'You have to link your youtube account to your google account first.'
- );
- }
-
- throw new BadBody(
- 'youtube',
- JSON.stringify(err.response.data),
- JSON.stringify(err.response.data),
- 'An error occurred while uploading your video, please try again later.'
- );
- }
+ }),
+ true
+ );
if (settings?.thumbnail?.path) {
- try {
- await this.runInConcurrent(async () =>
- youtubeClient.thumbnails.set({
- videoId: all?.data?.id!,
- media: {
- body: (
- await axios({
- url: settings?.thumbnail?.path,
- method: 'GET',
- responseType: 'stream',
- })
- ).data,
- },
- })
- );
- } catch (err: any) {
- if (
- err.response?.data?.error?.errors?.[0]?.domain === 'youtube.thumbnail'
- ) {
- throw new BadBody(
- '',
- JSON.stringify(err.response.data),
- JSON.stringify(err.response.data),
- 'Your account is not verified, we have uploaded your video but we could not set the thumbnail. Please verify your account and try again.'
- );
- }
- }
+ await this.runInConcurrent(async () =>
+ youtubeClient.thumbnails.set({
+ videoId: all?.data?.id!,
+ media: {
+ body: (
+ await axios({
+ url: settings?.thumbnail?.path,
+ method: 'GET',
+ responseType: 'stream',
+ })
+ ).data,
+ },
+ })
+ );
}
return [
diff --git a/libraries/nestjs-libraries/src/integrations/tool.decorator.ts b/libraries/nestjs-libraries/src/integrations/tool.decorator.ts
new file mode 100644
index 00000000..d4759d46
--- /dev/null
+++ b/libraries/nestjs-libraries/src/integrations/tool.decorator.ts
@@ -0,0 +1,17 @@
+import 'reflect-metadata';
+
+export function Tool(params: {
+ description: string;
+ dataSchema: Array<{ key: string; type: string; description: string }>;
+}) {
+ return function (target: any, propertyKey: string | symbol) {
+ // Retrieve existing metadata or initialize an empty array
+ const existingMetadata = Reflect.getMetadata('custom:tool', target) || [];
+
+ // Add the metadata information for this method
+ existingMetadata.push({ methodName: propertyKey, ...params });
+
+ // Define metadata on the class prototype (so it can be retrieved from the class)
+ Reflect.defineMetadata('custom:tool', existingMetadata, target);
+ };
+}
diff --git a/libraries/nestjs-libraries/src/mcp/mcp.service.ts b/libraries/nestjs-libraries/src/mcp/mcp.service.ts
deleted file mode 100644
index 6a7a4922..00000000
--- a/libraries/nestjs-libraries/src/mcp/mcp.service.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { Injectable } from '@nestjs/common';
-import EventEmitter from 'events';
-import { finalize, fromEvent, startWith } from 'rxjs';
-import { McpTransport } from '@gitroom/nestjs-libraries/mcp/mcp.transport';
-import { JSONRPCMessageSchema } from '@gitroom/nestjs-libraries/mcp/mcp.types';
-import { McpSettings } from '@gitroom/nestjs-libraries/mcp/mcp.settings';
-import { MainMcp } from '@gitroom/backend/mcp/main.mcp';
-
-@Injectable()
-export class McpService {
- static event = new EventEmitter();
- constructor(private _mainMcp: MainMcp) {}
-
- async runServer(apiKey: string, organization: string) {
- const server = McpSettings.load(organization, this._mainMcp).server();
- const transport = new McpTransport(organization);
-
- const observer = fromEvent(
- McpService.event,
- `organization-${organization}`
- ).pipe(
- startWith({
- type: 'endpoint',
- data:
- process.env.NEXT_PUBLIC_BACKEND_URL + '/mcp/' + apiKey + '/messages',
- }),
- finalize(() => {
- transport.close();
- })
- );
-
- await server.connect(transport);
-
- return observer;
- }
-
- async processPostBody(organization: string, body: object) {
- const server = McpSettings.load(organization, this._mainMcp).server();
- const message = JSONRPCMessageSchema.parse(body);
- const transport = new McpTransport(organization);
- await server.connect(transport);
- transport.handlePostMessage(message);
- return {};
- }
-}
diff --git a/libraries/nestjs-libraries/src/mcp/mcp.settings.ts b/libraries/nestjs-libraries/src/mcp/mcp.settings.ts
deleted file mode 100644
index 377dd254..00000000
--- a/libraries/nestjs-libraries/src/mcp/mcp.settings.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
-import { MainMcp } from '@gitroom/backend/mcp/main.mcp';
-import { socialIntegrationList } from '@gitroom/nestjs-libraries/integrations/integration.manager';
-
-export class McpSettings {
- private _server: McpServer;
- createServer(organization: string, service: MainMcp) {
- this._server = new McpServer(
- {
- name: 'Postiz',
- version: '2.0.0',
- },
- {
- instructions: `Postiz is a service to schedule social media posts for ${socialIntegrationList
- .map((p) => p.name)
- .join(
- ', '
- )} to schedule you need to have the providerId (you can get it from POSTIZ_PROVIDERS_LIST), user need to specify the schedule date (or now), text, you also can send base64 images and text for the comments. When you get POSTIZ_PROVIDERS_LIST, always display all the options to the user`,
- }
- );
-
- for (const usePrompt of Reflect.getMetadata(
- 'MCP_PROMPT',
- MainMcp.prototype
- ) || []) {
- const list = [
- usePrompt.data.promptName,
- usePrompt.data.zod,
- async (...args: any[]) => {
- return {
- // @ts-ignore
- messages: await service[usePrompt.func as string](
- organization,
- ...args
- ),
- };
- },
- ].filter((f) => f);
- this._server.prompt(...(list as [any, any, any]));
- }
-
- for (const usePrompt of Reflect.getMetadata(
- 'MCP_TOOL',
- MainMcp.prototype
- ) || []) {
- const list: any[] = [
- usePrompt.data.toolName,
- usePrompt.data.zod,
- async (...args: any[]) => {
- return {
- // @ts-ignore
- content: await service[usePrompt.func as string](
- organization,
- ...args
- ),
- };
- },
- ].filter((f) => f);
-
- this._server.tool(...(list as [any, any, any]));
- }
-
- return this;
- }
-
- server() {
- return this._server;
- }
-
- static load(organization: string, service: MainMcp): McpSettings {
- return new McpSettings().createServer(organization, service);
- }
-}
diff --git a/libraries/nestjs-libraries/src/mcp/mcp.tool.ts b/libraries/nestjs-libraries/src/mcp/mcp.tool.ts
deleted file mode 100644
index 101ef75b..00000000
--- a/libraries/nestjs-libraries/src/mcp/mcp.tool.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { ZodRawShape } from 'zod';
-
-export function McpTool(params: { toolName: string; zod?: ZodRawShape }) {
- return function (
- target: any,
- propertyKey: string | symbol,
- descriptor: PropertyDescriptor
- ) {
- const existingMetadata = Reflect.getMetadata('MCP_TOOL', target) || [];
-
- // Add the metadata information for this method
- existingMetadata.push({ data: params, func: propertyKey });
-
- // Define metadata on the class prototype (so it can be retrieved from the class)
- Reflect.defineMetadata('MCP_TOOL', existingMetadata, target);
- };
-}
-
-export function McpPrompt(params: { promptName: string; zod?: ZodRawShape }) {
- return function (
- target: any,
- propertyKey: string | symbol,
- descriptor: PropertyDescriptor
- ) {
- const existingMetadata = Reflect.getMetadata('MCP_PROMPT', target) || [];
-
- // Add the metadata information for this method
- existingMetadata.push({ data: params, func: propertyKey });
-
- // Define metadata on the class prototype (so it can be retrieved from the class)
- Reflect.defineMetadata('MCP_PROMPT', existingMetadata, target);
- };
-}
diff --git a/libraries/nestjs-libraries/src/mcp/mcp.transport.ts b/libraries/nestjs-libraries/src/mcp/mcp.transport.ts
deleted file mode 100644
index b5e6b72e..00000000
--- a/libraries/nestjs-libraries/src/mcp/mcp.transport.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
-import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service';
-import {
- JSONRPCMessage,
- JSONRPCMessageSchema,
-} from '@gitroom/nestjs-libraries/mcp/mcp.types';
-
-export class McpTransport implements Transport {
- constructor(private _organization: string) {}
-
- onclose?: () => void;
- onerror?: (error: Error) => void;
- onmessage?: (message: JSONRPCMessage) => void;
-
- async start() {}
-
- async send(message: JSONRPCMessage): Promise {
- McpService.event.emit(`organization-${this._organization}`, {
- type: 'message',
- data: JSON.stringify(message),
- });
- }
-
- async close() {
- McpService.event.removeAllListeners(`organization-${this._organization}`);
- }
-
- handlePostMessage(message: any) {
- let parsedMessage: JSONRPCMessage;
-
- try {
- parsedMessage = JSONRPCMessageSchema.parse(message);
- } catch (error) {
- this.onerror?.(error as Error);
- throw error;
- }
-
- this.onmessage?.(parsedMessage);
- }
-
- get sessionId() {
- return this._organization;
- }
-}
diff --git a/libraries/nestjs-libraries/src/mcp/mcp.types.ts b/libraries/nestjs-libraries/src/mcp/mcp.types.ts
deleted file mode 100644
index 5d7dc42f..00000000
--- a/libraries/nestjs-libraries/src/mcp/mcp.types.ts
+++ /dev/null
@@ -1,1309 +0,0 @@
-import { z, ZodTypeAny } from 'zod';
-
-export const LATEST_PROTOCOL_VERSION = '2024-11-05';
-export const SUPPORTED_PROTOCOL_VERSIONS = [
- LATEST_PROTOCOL_VERSION,
- '2024-10-07',
-];
-
-/* JSON-RPC types */
-export const JSONRPC_VERSION = '2.0';
-
-/**
- * A progress token, used to associate progress notifications with the original request.
- */
-export const ProgressTokenSchema = z.union([z.string(), z.number().int()]);
-
-/**
- * An opaque token used to represent a cursor for pagination.
- */
-export const CursorSchema = z.string();
-
-const BaseRequestParamsSchema = z
- .object({
- _meta: z.optional(
- z
- .object({
- /**
- * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications.
- */
- progressToken: z.optional(ProgressTokenSchema),
- })
- .passthrough()
- ),
- })
- .passthrough();
-
-export const RequestSchema = z.object({
- method: z.string(),
- params: z.optional(BaseRequestParamsSchema),
-});
-
-const BaseNotificationParamsSchema = z
- .object({
- /**
- * This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.
- */
- _meta: z.optional(z.object({}).passthrough()),
- })
- .passthrough();
-
-export const NotificationSchema = z.object({
- method: z.string(),
- params: z.optional(BaseNotificationParamsSchema),
-});
-
-export const ResultSchema = z
- .object({
- /**
- * This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.
- */
- _meta: z.optional(z.object({}).passthrough()),
- })
- .passthrough();
-
-/**
- * A uniquely identifying ID for a request in JSON-RPC.
- */
-export const RequestIdSchema = z.union([z.string(), z.number().int()]);
-
-/**
- * A request that expects a response.
- */
-export const JSONRPCRequestSchema = z
- .object({
- jsonrpc: z.literal(JSONRPC_VERSION),
- id: RequestIdSchema,
- })
- .merge(RequestSchema)
- .strict();
-
-export const isJSONRPCRequest = (value: unknown): value is JSONRPCRequest =>
- JSONRPCRequestSchema.safeParse(value).success;
-
-/**
- * A notification which does not expect a response.
- */
-export const JSONRPCNotificationSchema = z
- .object({
- jsonrpc: z.literal(JSONRPC_VERSION),
- })
- .merge(NotificationSchema)
- .strict();
-
-export const isJSONRPCNotification = (
- value: unknown
-): value is JSONRPCNotification =>
- JSONRPCNotificationSchema.safeParse(value).success;
-
-/**
- * A successful (non-error) response to a request.
- */
-export const JSONRPCResponseSchema = z
- .object({
- jsonrpc: z.literal(JSONRPC_VERSION),
- id: RequestIdSchema,
- result: ResultSchema,
- })
- .strict();
-
-export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse =>
- JSONRPCResponseSchema.safeParse(value).success;
-
-/**
- * Error codes defined by the JSON-RPC specification.
- */
-export enum ErrorCode {
- // SDK error codes
- ConnectionClosed = -32000,
- RequestTimeout = -32001,
-
- // Standard JSON-RPC error codes
- ParseError = -32700,
- InvalidRequest = -32600,
- MethodNotFound = -32601,
- InvalidParams = -32602,
- InternalError = -32603,
-}
-
-/**
- * A response to a request that indicates an error occurred.
- */
-export const JSONRPCErrorSchema = z
- .object({
- jsonrpc: z.literal(JSONRPC_VERSION),
- id: RequestIdSchema,
- error: z.object({
- /**
- * The error type that occurred.
- */
- code: z.number().int(),
- /**
- * A short description of the error. The message SHOULD be limited to a concise single sentence.
- */
- message: z.string(),
- /**
- * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.).
- */
- data: z.optional(z.unknown()),
- }),
- })
- .strict();
-
-export const isJSONRPCError = (value: unknown): value is JSONRPCError =>
- JSONRPCErrorSchema.safeParse(value).success;
-
-export const JSONRPCMessageSchema = z.union([
- JSONRPCRequestSchema,
- JSONRPCNotificationSchema,
- JSONRPCResponseSchema,
- JSONRPCErrorSchema,
-]);
-
-/* Empty result */
-/**
- * A response that indicates success but carries no data.
- */
-export const EmptyResultSchema = ResultSchema.strict();
-
-/* Cancellation */
-/**
- * This notification can be sent by either side to indicate that it is cancelling a previously-issued request.
- *
- * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.
- *
- * This notification indicates that the result will be unused, so any associated processing SHOULD cease.
- *
- * A client MUST NOT attempt to cancel its `initialize` request.
- */
-export const CancelledNotificationSchema = NotificationSchema.extend({
- method: z.literal('notifications/cancelled'),
- params: BaseNotificationParamsSchema.extend({
- /**
- * The ID of the request to cancel.
- *
- * This MUST correspond to the ID of a request previously issued in the same direction.
- */
- requestId: RequestIdSchema,
-
- /**
- * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.
- */
- reason: z.string().optional(),
- }),
-});
-
-/* Initialization */
-/**
- * Describes the name and version of an MCP implementation.
- */
-export const ImplementationSchema = z
- .object({
- name: z.string(),
- version: z.string(),
- })
- .passthrough();
-
-/**
- * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.
- */
-export const ClientCapabilitiesSchema = z
- .object({
- /**
- * Experimental, non-standard capabilities that the client supports.
- */
- experimental: z.optional(z.object({}).passthrough()),
- /**
- * Present if the client supports sampling from an LLM.
- */
- sampling: z.optional(z.object({}).passthrough()),
- /**
- * Present if the client supports listing roots.
- */
- roots: z.optional(
- z
- .object({
- /**
- * Whether the client supports issuing notifications for changes to the roots list.
- */
- listChanged: z.optional(z.boolean()),
- })
- .passthrough()
- ),
- })
- .passthrough();
-
-/**
- * This request is sent from the client to the server when it first connects, asking it to begin initialization.
- */
-export const InitializeRequestSchema = RequestSchema.extend({
- method: z.literal('initialize'),
- params: BaseRequestParamsSchema.extend({
- /**
- * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well.
- */
- protocolVersion: z.string(),
- capabilities: ClientCapabilitiesSchema,
- clientInfo: ImplementationSchema,
- }),
-});
-
-/**
- * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.
- */
-export const ServerCapabilitiesSchema = z
- .object({
- /**
- * Experimental, non-standard capabilities that the server supports.
- */
- experimental: z.optional(z.object({}).passthrough()),
- /**
- * Present if the server supports sending log messages to the client.
- */
- logging: z.optional(z.object({}).passthrough()),
- /**
- * Present if the server supports sending completions to the client.
- */
- completions: z.optional(z.object({}).passthrough()),
- /**
- * Present if the server offers any prompt templates.
- */
- prompts: z.optional(
- z
- .object({
- /**
- * Whether this server supports issuing notifications for changes to the prompt list.
- */
- listChanged: z.optional(z.boolean()),
- })
- .passthrough()
- ),
- /**
- * Present if the server offers any resources to read.
- */
- resources: z.optional(
- z
- .object({
- /**
- * Whether this server supports clients subscribing to resource updates.
- */
- subscribe: z.optional(z.boolean()),
-
- /**
- * Whether this server supports issuing notifications for changes to the resource list.
- */
- listChanged: z.optional(z.boolean()),
- })
- .passthrough()
- ),
- /**
- * Present if the server offers any tools to call.
- */
- tools: z.optional(
- z
- .object({
- /**
- * Whether this server supports issuing notifications for changes to the tool list.
- */
- listChanged: z.optional(z.boolean()),
- })
- .passthrough()
- ),
- })
- .passthrough();
-
-/**
- * After receiving an initialize request from the client, the server sends this response.
- */
-export const InitializeResultSchema = ResultSchema.extend({
- /**
- * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect.
- */
- protocolVersion: z.string(),
- capabilities: ServerCapabilitiesSchema,
- serverInfo: ImplementationSchema,
- /**
- * Instructions describing how to use the server and its features.
- *
- * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt.
- */
- instructions: z.optional(z.string()),
-});
-
-/**
- * This notification is sent from the client to the server after initialization has finished.
- */
-export const InitializedNotificationSchema = NotificationSchema.extend({
- method: z.literal('notifications/initialized'),
-});
-
-/* Ping */
-/**
- * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.
- */
-export const PingRequestSchema = RequestSchema.extend({
- method: z.literal('ping'),
-});
-
-/* Progress notifications */
-export const ProgressSchema = z
- .object({
- /**
- * The progress thus far. This should increase every time progress is made, even if the total is unknown.
- */
- progress: z.number(),
- /**
- * Total number of items to process (or total progress required), if known.
- */
- total: z.optional(z.number()),
- })
- .passthrough();
-
-/**
- * An out-of-band notification used to inform the receiver of a progress update for a long-running request.
- */
-export const ProgressNotificationSchema = NotificationSchema.extend({
- method: z.literal('notifications/progress'),
- params: BaseNotificationParamsSchema.merge(ProgressSchema).extend({
- /**
- * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding.
- */
- progressToken: ProgressTokenSchema,
- }),
-});
-
-/* Pagination */
-export const PaginatedRequestSchema = RequestSchema.extend({
- params: BaseRequestParamsSchema.extend({
- /**
- * An opaque token representing the current pagination position.
- * If provided, the server should return results starting after this cursor.
- */
- cursor: z.optional(CursorSchema),
- }).optional(),
-});
-
-export const PaginatedResultSchema = ResultSchema.extend({
- /**
- * An opaque token representing the pagination position after the last returned result.
- * If present, there may be more results available.
- */
- nextCursor: z.optional(CursorSchema),
-});
-
-/* Resources */
-/**
- * The contents of a specific resource or sub-resource.
- */
-export const ResourceContentsSchema = z
- .object({
- /**
- * The URI of this resource.
- */
- uri: z.string(),
- /**
- * The MIME type of this resource, if known.
- */
- mimeType: z.optional(z.string()),
- })
- .passthrough();
-
-export const TextResourceContentsSchema = ResourceContentsSchema.extend({
- /**
- * The text of the item. This must only be set if the item can actually be represented as text (not binary data).
- */
- text: z.string(),
-});
-
-export const BlobResourceContentsSchema = ResourceContentsSchema.extend({
- /**
- * A base64-encoded string representing the binary data of the item.
- */
- blob: z.string().base64(),
-});
-
-/**
- * A known resource that the server is capable of reading.
- */
-export const ResourceSchema = z
- .object({
- /**
- * The URI of this resource.
- */
- uri: z.string(),
-
- /**
- * A human-readable name for this resource.
- *
- * This can be used by clients to populate UI elements.
- */
- name: z.string(),
-
- /**
- * A description of what this resource represents.
- *
- * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model.
- */
- description: z.optional(z.string()),
-
- /**
- * The MIME type of this resource, if known.
- */
- mimeType: z.optional(z.string()),
- })
- .passthrough();
-
-/**
- * A template description for resources available on the server.
- */
-export const ResourceTemplateSchema = z
- .object({
- /**
- * A URI template (according to RFC 6570) that can be used to construct resource URIs.
- */
- uriTemplate: z.string(),
-
- /**
- * A human-readable name for the type of resource this template refers to.
- *
- * This can be used by clients to populate UI elements.
- */
- name: z.string(),
-
- /**
- * A description of what this template is for.
- *
- * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model.
- */
- description: z.optional(z.string()),
-
- /**
- * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.
- */
- mimeType: z.optional(z.string()),
- })
- .passthrough();
-
-/**
- * Sent from the client to request a list of resources the server has.
- */
-export const ListResourcesRequestSchema = PaginatedRequestSchema.extend({
- method: z.literal('resources/list'),
-});
-
-/**
- * The server's response to a resources/list request from the client.
- */
-export const ListResourcesResultSchema = PaginatedResultSchema.extend({
- resources: z.array(ResourceSchema),
-});
-
-/**
- * Sent from the client to request a list of resource templates the server has.
- */
-export const ListResourceTemplatesRequestSchema = PaginatedRequestSchema.extend(
- {
- method: z.literal('resources/templates/list'),
- }
-);
-
-/**
- * The server's response to a resources/templates/list request from the client.
- */
-export const ListResourceTemplatesResultSchema = PaginatedResultSchema.extend({
- resourceTemplates: z.array(ResourceTemplateSchema),
-});
-
-/**
- * Sent from the client to the server, to read a specific resource URI.
- */
-export const ReadResourceRequestSchema = RequestSchema.extend({
- method: z.literal('resources/read'),
- params: BaseRequestParamsSchema.extend({
- /**
- * The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it.
- */
- uri: z.string(),
- }),
-});
-
-/**
- * The server's response to a resources/read request from the client.
- */
-export const ReadResourceResultSchema = ResultSchema.extend({
- contents: z.array(
- z.union([TextResourceContentsSchema, BlobResourceContentsSchema])
- ),
-});
-
-/**
- * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.
- */
-export const ResourceListChangedNotificationSchema = NotificationSchema.extend({
- method: z.literal('notifications/resources/list_changed'),
-});
-
-/**
- * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes.
- */
-export const SubscribeRequestSchema = RequestSchema.extend({
- method: z.literal('resources/subscribe'),
- params: BaseRequestParamsSchema.extend({
- /**
- * The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it.
- */
- uri: z.string(),
- }),
-});
-
-/**
- * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request.
- */
-export const UnsubscribeRequestSchema = RequestSchema.extend({
- method: z.literal('resources/unsubscribe'),
- params: BaseRequestParamsSchema.extend({
- /**
- * The URI of the resource to unsubscribe from.
- */
- uri: z.string(),
- }),
-});
-
-/**
- * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request.
- */
-export const ResourceUpdatedNotificationSchema = NotificationSchema.extend({
- method: z.literal('notifications/resources/updated'),
- params: BaseNotificationParamsSchema.extend({
- /**
- * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.
- */
- uri: z.string(),
- }),
-});
-
-/* Prompts */
-/**
- * Describes an argument that a prompt can accept.
- */
-export const PromptArgumentSchema = z
- .object({
- /**
- * The name of the argument.
- */
- name: z.string(),
- /**
- * A human-readable description of the argument.
- */
- description: z.optional(z.string()),
- /**
- * Whether this argument must be provided.
- */
- required: z.optional(z.boolean()),
- })
- .passthrough();
-
-/**
- * A prompt or prompt template that the server offers.
- */
-export const PromptSchema = z
- .object({
- /**
- * The name of the prompt or prompt template.
- */
- name: z.string(),
- /**
- * An optional description of what this prompt provides
- */
- description: z.optional(z.string()),
- /**
- * A list of arguments to use for templating the prompt.
- */
- arguments: z.optional(z.array(PromptArgumentSchema)),
- })
- .passthrough();
-
-/**
- * Sent from the client to request a list of prompts and prompt templates the server has.
- */
-export const ListPromptsRequestSchema = PaginatedRequestSchema.extend({
- method: z.literal('prompts/list'),
-});
-
-/**
- * The server's response to a prompts/list request from the client.
- */
-export const ListPromptsResultSchema = PaginatedResultSchema.extend({
- prompts: z.array(PromptSchema),
-});
-
-/**
- * Used by the client to get a prompt provided by the server.
- */
-export const GetPromptRequestSchema = RequestSchema.extend({
- method: z.literal('prompts/get'),
- params: BaseRequestParamsSchema.extend({
- /**
- * The name of the prompt or prompt template.
- */
- name: z.string(),
- /**
- * Arguments to use for templating the prompt.
- */
- arguments: z.optional(z.record(z.string())),
- }),
-});
-
-/**
- * Text provided to or from an LLM.
- */
-export const TextContentSchema = z
- .object({
- type: z.literal('text'),
- /**
- * The text content of the message.
- */
- text: z.string(),
- })
- .passthrough();
-
-/**
- * An image provided to or from an LLM.
- */
-export const ImageContentSchema = z
- .object({
- type: z.literal('image'),
- /**
- * The base64-encoded image data.
- */
- data: z.string().base64(),
- /**
- * The MIME type of the image. Different providers may support different image types.
- */
- mimeType: z.string(),
- })
- .passthrough();
-
-/**
- * An Audio provided to or from an LLM.
- */
-export const AudioContentSchema = z
- .object({
- type: z.literal('audio'),
- /**
- * The base64-encoded audio data.
- */
- data: z.string().base64(),
- /**
- * The MIME type of the audio. Different providers may support different audio types.
- */
- mimeType: z.string(),
- })
- .passthrough();
-
-/**
- * The contents of a resource, embedded into a prompt or tool call result.
- */
-export const EmbeddedResourceSchema = z
- .object({
- type: z.literal('resource'),
- resource: z.union([TextResourceContentsSchema, BlobResourceContentsSchema]),
- })
- .passthrough();
-
-/**
- * Describes a message returned as part of a prompt.
- */
-export const PromptMessageSchema = z
- .object({
- role: z.enum(['user', 'assistant']),
- content: z.union([
- TextContentSchema,
- ImageContentSchema,
- AudioContentSchema,
- EmbeddedResourceSchema,
- ]),
- })
- .passthrough();
-
-/**
- * The server's response to a prompts/get request from the client.
- */
-export const GetPromptResultSchema = ResultSchema.extend({
- /**
- * An optional description for the prompt.
- */
- description: z.optional(z.string()),
- messages: z.array(PromptMessageSchema),
-});
-
-/**
- * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.
- */
-export const PromptListChangedNotificationSchema = NotificationSchema.extend({
- method: z.literal('notifications/prompts/list_changed'),
-});
-
-/* Tools */
-/**
- * Definition for a tool the client can call.
- */
-export const ToolSchema = z
- .object({
- /**
- * The name of the tool.
- */
- name: z.string(),
- /**
- * A human-readable description of the tool.
- */
- description: z.optional(z.string()),
- /**
- * A JSON Schema object defining the expected parameters for the tool.
- */
- inputSchema: z
- .object({
- type: z.literal('object'),
- properties: z.optional(z.object({}).passthrough()),
- })
- .passthrough(),
- })
- .passthrough();
-
-/**
- * Sent from the client to request a list of tools the server has.
- */
-export const ListToolsRequestSchema = PaginatedRequestSchema.extend({
- method: z.literal('tools/list'),
-});
-
-/**
- * The server's response to a tools/list request from the client.
- */
-export const ListToolsResultSchema = PaginatedResultSchema.extend({
- tools: z.array(ToolSchema),
-});
-
-/**
- * The server's response to a tool call.
- */
-export const CallToolResultSchema = ResultSchema.extend({
- content: z.array(
- z.union([
- TextContentSchema,
- ImageContentSchema,
- AudioContentSchema,
- EmbeddedResourceSchema,
- ])
- ),
- isError: z.boolean().default(false).optional(),
-});
-
-/**
- * CallToolResultSchema extended with backwards compatibility to protocol version 2024-10-07.
- */
-export const CompatibilityCallToolResultSchema = CallToolResultSchema.or(
- ResultSchema.extend({
- toolResult: z.unknown(),
- })
-);
-
-/**
- * Used by the client to invoke a tool provided by the server.
- */
-export const CallToolRequestSchema = RequestSchema.extend({
- method: z.literal('tools/call'),
- params: BaseRequestParamsSchema.extend({
- name: z.string(),
- arguments: z.optional(z.record(z.unknown())),
- }),
-});
-
-/**
- * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.
- */
-export const ToolListChangedNotificationSchema = NotificationSchema.extend({
- method: z.literal('notifications/tools/list_changed'),
-});
-
-/* Logging */
-/**
- * The severity of a log message.
- */
-export const LoggingLevelSchema = z.enum([
- 'debug',
- 'info',
- 'notice',
- 'warning',
- 'error',
- 'critical',
- 'alert',
- 'emergency',
-]);
-
-/**
- * A request from the client to the server, to enable or adjust logging.
- */
-export const SetLevelRequestSchema = RequestSchema.extend({
- method: z.literal('logging/setLevel'),
- params: BaseRequestParamsSchema.extend({
- /**
- * The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/logging/message.
- */
- level: LoggingLevelSchema,
- }),
-});
-
-/**
- * Notification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.
- */
-export const LoggingMessageNotificationSchema = NotificationSchema.extend({
- method: z.literal('notifications/message'),
- params: BaseNotificationParamsSchema.extend({
- /**
- * The severity of this log message.
- */
- level: LoggingLevelSchema,
- /**
- * An optional name of the logger issuing this message.
- */
- logger: z.optional(z.string()),
- /**
- * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here.
- */
- data: z.unknown(),
- }),
-});
-
-/* Sampling */
-/**
- * Hints to use for model selection.
- */
-export const ModelHintSchema = z
- .object({
- /**
- * A hint for a model name.
- */
- name: z.string().optional(),
- })
- .passthrough();
-
-/**
- * The server's preferences for model selection, requested of the client during sampling.
- */
-export const ModelPreferencesSchema = z
- .object({
- /**
- * Optional hints to use for model selection.
- */
- hints: z.optional(z.array(ModelHintSchema)),
- /**
- * How much to prioritize cost when selecting a model.
- */
- costPriority: z.optional(z.number().min(0).max(1)),
- /**
- * How much to prioritize sampling speed (latency) when selecting a model.
- */
- speedPriority: z.optional(z.number().min(0).max(1)),
- /**
- * How much to prioritize intelligence and capabilities when selecting a model.
- */
- intelligencePriority: z.optional(z.number().min(0).max(1)),
- })
- .passthrough();
-
-/**
- * Describes a message issued to or received from an LLM API.
- */
-export const SamplingMessageSchema = z
- .object({
- role: z.enum(['user', 'assistant']),
- content: z.union([
- TextContentSchema,
- ImageContentSchema,
- AudioContentSchema,
- ]),
- })
- .passthrough();
-
-/**
- * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.
- */
-export const CreateMessageRequestSchema = RequestSchema.extend({
- method: z.literal('sampling/createMessage'),
- params: BaseRequestParamsSchema.extend({
- messages: z.array(SamplingMessageSchema),
- /**
- * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.
- */
- systemPrompt: z.optional(z.string()),
- /**
- * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. The client MAY ignore this request.
- */
- includeContext: z.optional(z.enum(['none', 'thisServer', 'allServers'])),
- temperature: z.optional(z.number()),
- /**
- * The maximum number of tokens to sample, as requested by the server. The client MAY choose to sample fewer tokens than requested.
- */
- maxTokens: z.number().int(),
- stopSequences: z.optional(z.array(z.string())),
- /**
- * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific.
- */
- metadata: z.optional(z.object({}).passthrough()),
- /**
- * The server's preferences for which model to select.
- */
- modelPreferences: z.optional(ModelPreferencesSchema),
- }),
-});
-
-/**
- * The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it.
- */
-export const CreateMessageResultSchema = ResultSchema.extend({
- /**
- * The name of the model that generated the message.
- */
- model: z.string(),
- /**
- * The reason why sampling stopped.
- */
- stopReason: z.optional(
- z.enum(['endTurn', 'stopSequence', 'maxTokens']).or(z.string())
- ),
- role: z.enum(['user', 'assistant']),
- content: z.discriminatedUnion('type', [
- TextContentSchema,
- ImageContentSchema,
- AudioContentSchema,
- ]),
-});
-
-/* Autocomplete */
-/**
- * A reference to a resource or resource template definition.
- */
-export const ResourceReferenceSchema = z
- .object({
- type: z.literal('ref/resource'),
- /**
- * The URI or URI template of the resource.
- */
- uri: z.string(),
- })
- .passthrough();
-
-/**
- * Identifies a prompt.
- */
-export const PromptReferenceSchema = z
- .object({
- type: z.literal('ref/prompt'),
- /**
- * The name of the prompt or prompt template
- */
- name: z.string(),
- })
- .passthrough();
-
-/**
- * A request from the client to the server, to ask for completion options.
- */
-export const CompleteRequestSchema = RequestSchema.extend({
- method: z.literal('completion/complete'),
- params: BaseRequestParamsSchema.extend({
- ref: z.union([PromptReferenceSchema, ResourceReferenceSchema]),
- /**
- * The argument's information
- */
- argument: z
- .object({
- /**
- * The name of the argument
- */
- name: z.string(),
- /**
- * The value of the argument to use for completion matching.
- */
- value: z.string(),
- })
- .passthrough(),
- }),
-});
-
-/**
- * The server's response to a completion/complete request
- */
-export const CompleteResultSchema = ResultSchema.extend({
- completion: z
- .object({
- /**
- * An array of completion values. Must not exceed 100 items.
- */
- values: z.array(z.string()).max(100),
- /**
- * The total number of completion options available. This can exceed the number of values actually sent in the response.
- */
- total: z.optional(z.number().int()),
- /**
- * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.
- */
- hasMore: z.optional(z.boolean()),
- })
- .passthrough(),
-});
-
-/* Roots */
-/**
- * Represents a root directory or file that the server can operate on.
- */
-export const RootSchema = z
- .object({
- /**
- * The URI identifying the root. This *must* start with file:// for now.
- */
- uri: z.string().startsWith('file://'),
- /**
- * An optional name for the root.
- */
- name: z.optional(z.string()),
- })
- .passthrough();
-
-/**
- * Sent from the server to request a list of root URIs from the client.
- */
-export const ListRootsRequestSchema = RequestSchema.extend({
- method: z.literal('roots/list'),
-});
-
-/**
- * The client's response to a roots/list request from the server.
- */
-export const ListRootsResultSchema = ResultSchema.extend({
- roots: z.array(RootSchema),
-});
-
-/**
- * A notification from the client to the server, informing it that the list of roots has changed.
- */
-export const RootsListChangedNotificationSchema = NotificationSchema.extend({
- method: z.literal('notifications/roots/list_changed'),
-});
-
-/* Client messages */
-export const ClientRequestSchema = z.union([
- PingRequestSchema,
- InitializeRequestSchema,
- CompleteRequestSchema,
- SetLevelRequestSchema,
- GetPromptRequestSchema,
- ListPromptsRequestSchema,
- ListResourcesRequestSchema,
- ListResourceTemplatesRequestSchema,
- ReadResourceRequestSchema,
- SubscribeRequestSchema,
- UnsubscribeRequestSchema,
- CallToolRequestSchema,
- ListToolsRequestSchema,
-]);
-
-export const ClientNotificationSchema = z.union([
- CancelledNotificationSchema,
- ProgressNotificationSchema,
- InitializedNotificationSchema,
- RootsListChangedNotificationSchema,
-]);
-
-export const ClientResultSchema = z.union([
- EmptyResultSchema,
- CreateMessageResultSchema,
- ListRootsResultSchema,
-]);
-
-/* Server messages */
-export const ServerRequestSchema = z.union([
- PingRequestSchema,
- CreateMessageRequestSchema,
- ListRootsRequestSchema,
-]);
-
-export const ServerNotificationSchema = z.union([
- CancelledNotificationSchema,
- ProgressNotificationSchema,
- LoggingMessageNotificationSchema,
- ResourceUpdatedNotificationSchema,
- ResourceListChangedNotificationSchema,
- ToolListChangedNotificationSchema,
- PromptListChangedNotificationSchema,
-]);
-
-export const ServerResultSchema = z.union([
- EmptyResultSchema,
- InitializeResultSchema,
- CompleteResultSchema,
- GetPromptResultSchema,
- ListPromptsResultSchema,
- ListResourcesResultSchema,
- ListResourceTemplatesResultSchema,
- ReadResourceResultSchema,
- CallToolResultSchema,
- ListToolsResultSchema,
-]);
-
-export class McpError extends Error {
- constructor(
- public readonly code: number,
- message: string,
- public readonly data?: unknown
- ) {
- super(`MCP error ${code}: ${message}`);
- this.name = 'McpError';
- }
-}
-
-type Primitive = string | number | boolean | bigint | null | undefined;
-type Flatten = T extends Primitive
- ? T
- : T extends Array
- ? Array>
- : T extends Set
- ? Set>
- : T extends Map
- ? Map, Flatten>
- : T extends object
- ? { [K in keyof T]: Flatten }
- : T;
-
-type Infer = Flatten>;
-
-/* JSON-RPC types */
-export type ProgressToken = Infer;
-export type Cursor = Infer;
-export type Request = Infer;
-export type Notification = Infer;
-export type Result = Infer;
-export type RequestId = Infer;
-export type JSONRPCRequest = Infer;
-export type JSONRPCNotification = Infer;
-export type JSONRPCResponse = Infer;
-export type JSONRPCError = Infer;
-export type JSONRPCMessage = Infer;
-
-/* Empty result */
-export type EmptyResult = Infer;
-
-/* Cancellation */
-export type CancelledNotification = Infer;
-
-/* Initialization */
-export type Implementation = Infer;
-export type ClientCapabilities = Infer;
-export type InitializeRequest = Infer;
-export type ServerCapabilities = Infer;
-export type InitializeResult = Infer;
-export type InitializedNotification = Infer<
- typeof InitializedNotificationSchema
->;
-
-/* Ping */
-export type PingRequest = Infer;
-
-/* Progress notifications */
-export type Progress = Infer;
-export type ProgressNotification = Infer;
-
-/* Pagination */
-export type PaginatedRequest = Infer;
-export type PaginatedResult = Infer;
-
-/* Resources */
-export type ResourceContents = Infer;
-export type TextResourceContents = Infer;
-export type BlobResourceContents = Infer;
-export type Resource = Infer;
-export type ResourceTemplate = Infer;
-export type ListResourcesRequest = Infer;
-export type ListResourcesResult = Infer;
-export type ListResourceTemplatesRequest = Infer<
- typeof ListResourceTemplatesRequestSchema
->;
-export type ListResourceTemplatesResult = Infer<
- typeof ListResourceTemplatesResultSchema
->;
-export type ReadResourceRequest = Infer;
-export type ReadResourceResult = Infer;
-export type ResourceListChangedNotification = Infer<
- typeof ResourceListChangedNotificationSchema
->;
-export type SubscribeRequest = Infer;
-export type UnsubscribeRequest = Infer;
-export type ResourceUpdatedNotification = Infer<
- typeof ResourceUpdatedNotificationSchema
->;
-
-/* Prompts */
-export type PromptArgument = Infer;
-export type Prompt = Infer;
-export type ListPromptsRequest = Infer;
-export type ListPromptsResult = Infer;
-export type GetPromptRequest = Infer;
-export type TextContent = Infer;
-export type ImageContent = Infer;
-export type AudioContent = Infer;
-export type EmbeddedResource = Infer;
-export type PromptMessage = Infer