feat: agent

This commit is contained in:
Nevo David 2025-10-07 11:07:21 +07:00
parent ad7f7d9408
commit 73ba9acf8c
54 changed files with 6261 additions and 2033 deletions

28
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,28 @@
{
"explorer.compactFolders": false,
"explorer.fileNesting.enabled": false,
"editor.lineHeight": 27,
"editor.folding": true,
"editor.foldingImportsByDefault": true,
"editor.fontLigatures": false,
"gitlens.codeLens.enabled": false,
"gitlens.currentLine.enabled": false,
"typescript.updateImportsOnFileMove.enabled": "always",
"typescript.suggest.autoImports": true,
"javascript.suggest.autoImports": true,
"editor.codeActionsOnSave": {
"source.addMissingImports": "explicit"
},
"workbench.activityBar.orientation": "vertical",
"workbench.colorCustomizations": {
"activityBar.background": "#393c3e",
"editor.background": "#2b2b2b",
"editorGutter.background": "#2b2b2b",
"editorIndentGuide.background1": "#373737",
"editorIndentGuide.activeBackground1": "#587973",
"editor.lineHighlightBackground": "#323232",
"editor.selectionHighlightBackground": "#40404080",
"editor.selectionBackground": "#214283",
"editor.hoverHighlightBackground": "#40404080",
}
}

View File

@ -2,15 +2,29 @@ import { Logger, Controller, Get, Post, Req, Res, Query } from '@nestjs/common';
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNestEndpoint,
copilotRuntimeNodeHttpEndpoint,
} from '@copilotkit/runtime';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { MastraAgent } from '@ag-ui/mastra';
import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service';
import { Mastra } from '@mastra/core/dist/mastra';
import { Request, Response } from 'express';
import { RuntimeContext } from '@mastra/core/di';
let mastra: Mastra;
export type ChannelsContext = {
integrations: string;
organization: string;
};
@Controller('/copilot')
export class CopilotController {
constructor(private _subscriptionService: SubscriptionService) {}
constructor(
private _subscriptionService: SubscriptionService,
private _mastraService: MastraService
) {}
@Post('/chat')
chat(@Req() req: Request, @Res() res: Response) {
if (
@ -21,28 +35,67 @@ export class CopilotController {
return;
}
const copilotRuntimeHandler = copilotRuntimeNestEndpoint({
const copilotRuntimeHandler = copilotRuntimeNodeHttpEndpoint({
endpoint: '/copilot/chat',
runtime: new CopilotRuntime(),
serviceAdapter: new OpenAIAdapter({
model:
// @ts-ignore
req?.body?.variables?.data?.metadata?.requestType ===
'TextareaCompletion'
? 'gpt-4o-mini'
: 'gpt-4.1',
model: 'gpt-4.1',
}),
});
return copilotRuntimeHandler(req, res);
}
@Post('/agent')
async agent(
@Req() req: Request,
@Res() res: Response,
@GetOrgFromRequest() organization: Organization
) {
if (
process.env.OPENAI_API_KEY === undefined ||
process.env.OPENAI_API_KEY === ''
) {
Logger.warn('OpenAI API key not set, chat functionality will not work');
return;
}
mastra = mastra || (await this._mastraService.mastra());
const runtimeContext = new RuntimeContext<ChannelsContext>();
runtimeContext.set(
'integrations',
req?.body?.variables?.properties?.integrations || []
);
runtimeContext.set('organization', organization.id);
const runtime = new CopilotRuntime({
agents: MastraAgent.getLocalAgents({
mastra,
// @ts-ignore
runtimeContext,
}),
});
const copilotRuntimeHandler = copilotRuntimeNodeHttpEndpoint({
endpoint: '/copilot/agent',
runtime,
properties: req.body.variables.properties,
serviceAdapter: new OpenAIAdapter({
model: 'gpt-4.1',
}),
});
// @ts-ignore
return copilotRuntimeHandler(req, res);
}
@Get('/credits')
calculateCredits(
@GetOrgFromRequest() organization: Organization,
@Query('type') type: 'ai_images' | 'ai_videos',
@Query('type') type: 'ai_images' | 'ai_videos'
) {
return this._subscriptionService.checkCredits(organization, type || 'ai_images');
return this._subscriptionService.checkCredits(
organization,
type || 'ai_images'
);
}
}

View File

@ -11,8 +11,9 @@ import { AgentModule } from '@gitroom/nestjs-libraries/agent/agent.module';
import { McpModule } from '@gitroom/backend/mcp/mcp.module';
import { ThirdPartyModule } from '@gitroom/nestjs-libraries/3rdparties/thirdparty.module';
import { VideoModule } from '@gitroom/nestjs-libraries/videos/video.module';
import { SentryModule } from "@sentry/nestjs/setup";
import { SentryModule } from '@sentry/nestjs/setup';
import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
import { ChatModule } from '@gitroom/nestjs-libraries/chat/chat.module';
@Global()
@Module({
@ -26,6 +27,7 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
McpModule,
ThirdPartyModule,
VideoModule,
ChatModule,
ThrottlerModule.forRoot([
{
ttl: 3600000,
@ -43,7 +45,7 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
{
provide: APP_GUARD,
useClass: PoliciesGuard,
}
},
],
exports: [
BullMqModule,
@ -53,6 +55,7 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
AgentModule,
McpModule,
ThrottlerModule,
ChatModule,
],
})
export class AppModule {}

View File

@ -6,6 +6,7 @@ import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/o
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service';
export const removeAuth = (res: Response) => {
res.cookie('auth', '', {
@ -20,7 +21,6 @@ export const removeAuth = (res: Response) => {
expires: new Date(0),
maxAge: -1,
});
res.header('logout', 'true');
};

View File

@ -0,0 +1,11 @@
import { Metadata } from 'next';
import { Agent } from '@gitroom/frontend/components/agents/agent';
export const metadata: Metadata = {
title: 'Agent',
description: '',
};
export default async function Page() {
return (
<Agent />
);
}

View File

@ -678,4 +678,25 @@ html[dir='rtl'] [dir='ltr'] {
}
.blur-xs {
filter: blur(4px);
}
.agent {
.copilotKitInputContainer {
padding: 0 24px !important;
}
.copilotKitInput {
width: 100% !important;
}
}
.rm-bg .b2 {
padding-top: 0 !important;
}
.rm-bg .b1 {
background: transparent !important;
gap: 0 !important;
}
.copilotKitMessage img {
width: 200px;
}

View File

@ -0,0 +1,120 @@
import React, { useMemo, useRef, useState } from 'react';
import { useCopilotContext, useCopilotReadable } from '@copilotkit/react-core';
import AutoResizingTextarea from '@gitroom/frontend/components/agents/agent.textarea';
import { useChatContext } from '@copilotkit/react-ui';
import { InputProps } from '@copilotkit/react-ui/dist/components/chat/props';
const MAX_NEWLINES = 6;
export const Input = ({
inProgress,
onSend,
isVisible = false,
onStop,
onUpload,
hideStopButton = false,
onChange,
}: InputProps & { onChange: (value: string) => void }) => {
const context = useChatContext();
const copilotContext = useCopilotContext();
const showPoweredBy = !copilotContext.copilotApiConfig?.publicApiKey;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isComposing, setIsComposing] = useState(false);
const handleDivClick = (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
// If the user clicked a button or inside a button, don't focus the textarea
if (target.closest('button')) return;
// If the user clicked the textarea, do nothing (it's already focused)
if (target.tagName === 'TEXTAREA') return;
// Otherwise, focus the textarea
textareaRef.current?.focus();
};
const [text, setText] = useState('');
const send = () => {
if (inProgress) return;
onSend(text);
setText('');
textareaRef.current?.focus();
};
const isInProgress = inProgress;
const buttonIcon =
isInProgress && !hideStopButton
? context.icons.stopIcon
: context.icons.sendIcon;
const canSend = useMemo(() => {
const interruptEvent = copilotContext.langGraphInterruptAction?.event;
const interruptInProgress =
interruptEvent?.name === 'LangGraphInterruptEvent' &&
!interruptEvent?.response;
return !isInProgress && text.trim().length > 0 && !interruptInProgress;
}, [copilotContext.langGraphInterruptAction?.event, isInProgress, text]);
const canStop = useMemo(() => {
return isInProgress && !hideStopButton;
}, [isInProgress, hideStopButton]);
const sendDisabled = !canSend && !canStop;
return (
<div
className={`copilotKitInputContainer ${
showPoweredBy ? 'poweredByContainer' : ''
}`}
>
<div className="copilotKitInput" onClick={handleDivClick}>
<AutoResizingTextarea
ref={textareaRef}
placeholder={context.labels.placeholder}
autoFocus={false}
maxRows={MAX_NEWLINES}
value={text}
onChange={(event) => {
onChange(event.target.value);
setText(event.target.value);
}}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !isComposing) {
event.preventDefault();
if (canSend) {
send();
}
}
}}
/>
<div className="copilotKitInputControls">
{onUpload && (
<button onClick={onUpload} className="copilotKitInputControlButton">
{context.icons.uploadIcon}
</button>
)}
<div style={{ flexGrow: 1 }} />
<button
disabled={sendDisabled}
onClick={isInProgress && !hideStopButton ? onStop : send}
data-copilotkit-in-progress={inProgress}
data-test-id={
inProgress
? 'copilot-chat-request-in-progress'
: 'copilot-chat-ready'
}
className="copilotKitInputControlButton"
>
{buttonIcon}
</button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,77 @@
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";
interface AutoResizingTextareaProps {
maxRows?: number;
placeholder?: string;
value: string;
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCompositionStart?: () => void;
onCompositionEnd?: () => void;
autoFocus?: boolean;
}
const AutoResizingTextarea = forwardRef<HTMLTextAreaElement, AutoResizingTextareaProps>(
(
{
maxRows = 1,
placeholder,
value,
onChange,
onKeyDown,
onCompositionStart,
onCompositionEnd,
autoFocus,
},
ref,
) => {
const internalTextareaRef = useRef<HTMLTextAreaElement>(null);
const [maxHeight, setMaxHeight] = useState<number>(0);
useImperativeHandle(ref, () => internalTextareaRef.current as HTMLTextAreaElement);
useEffect(() => {
const calculateMaxHeight = () => {
const textarea = internalTextareaRef.current;
if (textarea) {
textarea.style.height = "auto";
const singleRowHeight = textarea.scrollHeight;
setMaxHeight(singleRowHeight * maxRows);
if (autoFocus) {
textarea.focus();
}
}
};
calculateMaxHeight();
}, [maxRows]);
useEffect(() => {
const textarea = internalTextareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
}
}, [value, maxHeight]);
return (
<textarea
ref={internalTextareaRef}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
placeholder={placeholder}
style={{
overflow: "auto",
resize: "none",
maxHeight: `${maxHeight}px`,
}}
rows={1}
/>
);
},
);
export default AutoResizingTextarea;

View File

@ -0,0 +1,342 @@
'use client';
import React, {
createContext,
FC,
useCallback,
useMemo,
useState,
useContext,
} from 'react';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { CopilotKit } from '@copilotkit/react-core';
import { CopilotChat, CopilotKitCSSProperties } from '@copilotkit/react-ui';
import clsx from 'clsx';
import useCookie from 'react-use-cookie';
import useSWR from 'swr';
import { orderBy } from 'lodash';
import { SVGLine } from '@gitroom/frontend/components/launches/launches.component';
import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
import Image from 'next/image';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useWaitForClass } from '@gitroom/helpers/utils/use.wait.for.class';
import { MultiMediaComponent } from '@gitroom/frontend/components/media/media.component';
import {
InputProps,
UserMessageProps,
} from '@copilotkit/react-ui/dist/components/chat/props';
import { Input } from '@gitroom/frontend/components/agents/agent.input';
import { Integration } from '@prisma/client';
export const MediaPortal: FC<{
media: { path: string; id: string }[];
value: string;
setMedia: (event: {
target: {
name: string;
value?: {
id: string;
path: string;
alt?: string;
thumbnail?: string;
thumbnailTimestamp?: number;
}[];
};
}) => void;
}> = ({ media, setMedia, value }) => {
const waitForClass = useWaitForClass('copilotKitMessages');
if (!waitForClass) return null;
return (
<div className="pl-[14px] pr-[24px] whitespace-nowrap editor rm-bg">
<MultiMediaComponent
allData={[{ content: value }]}
text={value}
label="Attachments"
description=""
value={media}
dummy={false}
name="image"
onChange={setMedia}
onOpen={() => {}}
onClose={() => {}}
/>
</div>
);
};
export const AgentList: FC<{ onChange: (arr: any[]) => void }> = ({
onChange,
}) => {
const fetch = useFetch();
const [selected, setSelected] = useState([]);
const load = useCallback(async () => {
return (await (await fetch('/integrations/list')).json()).integrations;
}, []);
const [collapseMenu, setCollapseMenu] = useCookie('collapseMenu', '0');
const { data } = useSWR('integrations', load, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
revalidateOnMount: true,
refreshWhenHidden: false,
refreshWhenOffline: false,
fallbackData: [],
});
const setIntegration = useCallback(
(integration: Integration) => () => {
if (selected.some((p) => p.id === integration.id)) {
onChange(selected.filter((p) => p.id !== integration.id));
setSelected(selected.filter((p) => p.id !== integration.id));
} else {
onChange([...selected, integration]);
setSelected([...selected, integration]);
}
},
[selected]
);
const sortedIntegrations = useMemo(() => {
return orderBy(
data || [],
['type', 'disabled', 'identifier'],
['desc', 'asc', 'asc']
);
}, [data]);
return (
<div
className={clsx(
'trz bg-newBgColorInner flex flex-col gap-[15px] transition-all relative',
collapseMenu === '1' ? 'group sidebar w-[100px]' : 'w-[260px]'
)}
>
<div className="absolute top-0 start-0 w-full h-full p-[20px] overflow-auto scrollbar scrollbar-thumb-fifth scrollbar-track-newBgColor">
<div className="mb-[15px] justify-center flex group-[.sidebar]:pb-[15px]">
<button className="text-white whitespace-nowrap flex-1 pt-[12px] pb-[14px] ps-[16px] pe-[20px] group-[.sidebar]:p-0 min-h-[44px] max-h-[44px] rounded-md bg-btnPrimary flex justify-center items-center gap-[5px] outline-none">
<svg
xmlns="http://www.w3.org/2000/svg"
width="21"
height="20"
viewBox="0 0 21 20"
fill="none"
className="min-w-[21px] min-h-[20px]"
>
<path
d="M10.5001 4.16699V15.8337M4.66675 10.0003H16.3334"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="flex-1 text-start text-[16px] group-[.sidebar]:hidden">
Start a new session
</div>
</button>
</div>
<div className="flex items-center">
<h2 className="group-[.sidebar]:hidden flex-1 text-[20px] font-[500] mb-[15px]">
Select Channels
</h2>
<div
onClick={() => setCollapseMenu(collapseMenu === '1' ? '0' : '1')}
className="-mt-3 group-[.sidebar]:rotate-[180deg] group-[.sidebar]:mx-auto text-btnText bg-btnSimple rounded-[6px] w-[24px] h-[24px] flex items-center justify-center cursor-pointer select-none"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="7"
height="13"
viewBox="0 0 7 13"
fill="none"
>
<path
d="M6 11.5L1 6.5L6 1.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
<div className={clsx('flex flex-col gap-[15px]')}>
{sortedIntegrations.map((integration, index) => (
<div
onClick={setIntegration(integration)}
key={integration.id}
className={clsx(
'flex gap-[12px] items-center group/profile justify-center hover:bg-boxHover rounded-e-[8px] hover:opacity-100 cursor-pointer',
!selected.some((p) => p.id === integration.id) && 'opacity-20'
)}
>
<div
className={clsx(
'relative rounded-full flex justify-center items-center gap-[6px]',
integration.disabled && 'opacity-50'
)}
>
{(integration.inBetweenSteps || integration.refreshNeeded) && (
<div className="absolute start-0 top-0 w-[39px] h-[46px] cursor-pointer">
<div className="bg-red-500 w-[15px] h-[15px] rounded-full start-0 -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-primary/60 w-[39px] h-[46px] start-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<div className="h-full w-[4px] -ms-[12px] rounded-s-[3px] opacity-0 group-hover/profile:opacity-100 transition-opacity">
<SVGLine />
</div>
<ImageWithFallback
fallbackSrc={`/icons/platforms/${integration.identifier}.png`}
src={integration.picture}
className="rounded-[8px]"
alt={integration.identifier}
width={36}
height={36}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-[8px] absolute z-10 bottom-[5px] -end-[5px] border border-fifth"
alt={integration.identifier}
width={18.41}
height={18.41}
/>
</div>
<div
className={clsx(
'flex-1 whitespace-nowrap text-ellipsis overflow-hidden group-[.sidebar]:hidden',
integration.disabled && 'opacity-50'
)}
>
{integration.name}
</div>
</div>
))}
</div>
</div>
</div>
);
};
const PropertiesContext = createContext({ properties: [] });
export const Agent: FC = () => {
const { backendUrl } = useVariables();
const [properties, setProperties] = useState([]);
return (
<PropertiesContext.Provider value={{ properties }}>
<CopilotKit
credentials="include"
runtimeUrl={backendUrl + '/copilot/agent'}
showDevConsole={false}
// publicApiKey="ck_pub_35c5e2cef8891a02b99bffed73c53d8d"
agent="postiz"
properties={{
integrations: properties,
}}
>
<AgentList onChange={setProperties} />
<div
style={
{
'--copilot-kit-primary-color': 'var(--new-btn-text)',
'--copilot-kit-background-color': 'var(--new-bg-color)',
} as CopilotKitCSSProperties
}
className="trz agent bg-newBgColorInner flex flex-col gap-[15px] transition-all flex-1 items-center relative"
>
<div className="absolute left-0 w-full h-full pb-[20px]">
<CopilotChat
className="w-full h-full"
labels={{
title: 'Your Assistant',
initial: 'Hi! 👋 How can I assist you today?',
}}
UserMessage={Message}
Input={NewInput}
/>
</div>
</div>
</CopilotKit>
</PropertiesContext.Provider>
);
};
const Message: FC<UserMessageProps> = (props) => {
const convertContentToImagesAndVideo = useMemo(() => {
return (props.message?.content || '')
.replace(/Video: (http.*mp4\n)/g, (match, p1) => {
return `<video controls class="h-[150px] w-[150px] rounded-[8px] mb-[10px]"><source src="${p1.trim()}" type="video/mp4">Your browser does not support the video tag.</video>`;
})
.replace(/Image: (http.*\n)/g, (match, p1) => {
return `<img src="${p1.trim()}" class="h-[150px] w-[150px] max-w-full border border-newBgColorInner" />`;
})
.replace(/\[\-\-Media\-\-\](.*)\[\-\-Media\-\-\]/g, (match, p1) => {
return `<div class="flex justify-center mt-[20px]">${p1}</div>`;
})
.replace(
/(\[--integrations--\][\s\S]*?\[--integrations--\])/g,
(match, p1) => {
return ``;
}
);
}, [props.message?.content]);
return (
<div
className="copilotKitMessage copilotKitUserMessage min-w-[300px]"
dangerouslySetInnerHTML={{ __html: convertContentToImagesAndVideo }}
/>
);
};
const NewInput: FC<InputProps> = (props) => {
const [media, setMedia] = useState([] as { path: string; id: string }[]);
const [value, setValue] = useState('');
const { properties } = useContext(PropertiesContext);
return (
<>
<MediaPortal
value={value}
media={media}
setMedia={(e) => setMedia(e.target.value)}
/>
<Input
{...props}
onChange={setValue}
onSend={(text) => {
const send = props.onSend(
text +
(media.length > 0
? '\n[--Media--]' +
media
.map((m) =>
m.path.indexOf('mp4') > -1
? `Video: ${m.path}`
: `Image: ${m.path}`
)
.join('\n') +
'\n[--Media--]'
: '') +
`
[--integrations--]
Use the following social media platforms: ${JSON.stringify(
properties.map((p) => ({
id: p.id,
platform: p.identifier,
profilePicture: p.picture,
additionalSettings: p.additionalSettings,
}))
)}
[--integrations--]`
);
setValue('');
setMedia([]);
return send;
}}
/>
</>
);
};

View File

@ -20,6 +20,24 @@ export const useMenuItem = () => {
const t = useT();
const firstMenu = [
{
name: 'Agent',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="23"
height="23"
viewBox="0 0 32 32"
fill="none"
>
<path
d="M21.1963 9.07375C20.2913 6.95494 18.6824 5.21364 16.6416 4.14422C14.6009 3.0748 12.2534 2.74287 9.99616 3.20455C7.73891 3.66623 5.71031 4.8932 4.25334 6.67802C2.79637 8.46284 2.0004 10.696 2 13V21.25C2 21.7141 2.18437 22.1592 2.51256 22.4874C2.84075 22.8156 3.28587 23 3.75 23H10.8337C11.6141 24.7821 12.8964 26.2984 14.5241 27.3638C16.1519 28.4293 18.0546 28.9978 20 29H28.25C28.7141 29 29.1592 28.8156 29.4874 28.4874C29.8156 28.1592 30 27.7141 30 27.25V19C29.9995 16.5553 29.1036 14.1955 27.4814 12.3666C25.8593 10.5376 23.6234 9.36619 21.1963 9.07375ZM4 13C4 11.4177 4.46919 9.87103 5.34824 8.55544C6.22729 7.23984 7.47672 6.21446 8.93853 5.60896C10.4003 5.00346 12.0089 4.84504 13.5607 5.15372C15.1126 5.4624 16.538 6.22432 17.6569 7.34314C18.7757 8.46197 19.5376 9.88743 19.8463 11.4393C20.155 12.9911 19.9965 14.5997 19.391 16.0615C18.7855 17.5233 17.7602 18.7727 16.4446 19.6518C15.129 20.5308 13.5823 21 12 21H4V13ZM28 27H20C18.5854 26.9984 17.1964 26.6225 15.974 25.9106C14.7516 25.1986 13.7394 24.1759 13.04 22.9463C14.4096 22.8041 15.7351 22.3804 16.9333 21.7017C18.1314 21.023 19.1763 20.104 20.0024 19.0023C20.8284 17.9006 21.4179 16.6401 21.7337 15.2998C22.0495 13.9595 22.0848 12.5684 21.8375 11.2137C23.5916 11.6277 25.1545 12.6218 26.273 14.035C27.3915 15.4482 28 17.1977 28 19V27Z"
fill="currentColor"
/>
</svg>
),
path: '/agents',
},
{
name: isGeneral ? t('calendar', 'Calendar') : t('launches', 'Launches'),
icon: (

View File

@ -643,7 +643,7 @@ export const MultiMediaComponent: FC<{
return (
<>
<div className="flex flex-col gap-[8px] bg-bigStrip rounded-bl-[8px] select-none w-full">
<div className="b1 flex flex-col gap-[8px] bg-bigStrip rounded-bl-[8px] select-none w-full">
<div className="flex gap-[10px]">
<Button
onClick={showModal}
@ -757,8 +757,8 @@ export const MultiMediaComponent: FC<{
)}
</div>
{!dummy && (
<div className="flex gap-[10px] bg-newBgLineColor w-full">
<div className="flex py-[10px]">
<div className="flex gap-[10px] bg-newBgLineColor w-full b1">
<div className="flex py-[10px] b2">
<Button
onClick={designMedia}
className="ms-[10px] rounded-[4px] gap-[8px] !text-primary justify-center items-center w-[127px] flex border border-dashed border-newBgLineColor bg-newColColor"

View File

@ -1,4 +1,5 @@
'use client';
'use client';
import {
PostComment,

View File

@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
/**
* useWaitForClass
*
* Watches the DOM for the presence of a CSS class and resolves when found.
*
* @param className - The class to wait for (without the dot, e.g. "my-element")
* @param root - The root node to observe (defaults to document.body)
* @returns A boolean indicating if the class is currently present
*/
export function useWaitForClass(className: string, root: HTMLElement | null = null): boolean {
const [found, setFound] = useState(false);
useEffect(() => {
const target = root ?? document.body;
if (!target) return;
// Check immediately in case the element is already present
if (target.querySelector(`.${className}`)) {
setFound(true);
return;
}
const observer = new MutationObserver(() => {
if (target.querySelector(`.${className}`)) {
setFound(true);
observer.disconnect();
}
});
observer.observe(target, {
childList: true,
subtree: true,
attributes: true,
});
return () => observer.disconnect();
}, [className, root]);
return found;
}

View File

@ -0,0 +1,4 @@
export interface AgentToolInterface {
name: string;
run(): Promise<any>;
}

View File

@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { LoadToolsService } from '@gitroom/nestjs-libraries/chat/load.tools.service';
import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service';
import { toolList } from '@gitroom/nestjs-libraries/chat/tools/tool.list';
@Global()
@Module({
providers: [MastraService, LoadToolsService, ...toolList],
get exports() {
return this.providers;
},
})
export class ChatModule {}

View File

@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { Agent } from '@mastra/core/agent';
import { openai } from '@ai-sdk/openai';
import { Memory } from '@mastra/memory';
import { pStore } from '@gitroom/nestjs-libraries/chat/mastra.store';
import { array, object, string } from 'zod';
import { ModuleRef } from '@nestjs/core';
import { toolList } from '@gitroom/nestjs-libraries/chat/tools/tool.list';
import dayjs from 'dayjs';
export const AgentState = object({
proverbs: array(string()).default([]),
});
@Injectable()
export class LoadToolsService {
constructor(private _moduleRef: ModuleRef) {}
async loadTools() {
return (
await Promise.all<{ name: string; tool: any }>(
toolList
.map((p) => this._moduleRef.get(p, { strict: false }))
.map(async (p) => ({
name: p.name as string,
tool: await p.run(),
}))
)
).reduce(
(all, current) => ({
...all,
[current.name]: current.tool,
}),
{} as Record<string, any>
);
}
async agent() {
const tools = await this.loadTools();
return new Agent({
name: 'postiz',
instructions: () => {
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 text for posts
- Show global analytics about socials
- 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.
- 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).
- 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.
- Between tools, we will reference things like: [output:name] and [input:name] to set the information right.
`;
},
model: openai('gpt-4.1'),
tools,
memory: new Memory({
storage: pStore,
options: {
workingMemory: {
enabled: true,
schema: AgentState,
},
},
}),
});
}
}

View File

@ -0,0 +1,21 @@
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 {
constructor(private _loadToolsService: LoadToolsService) {}
async mastra() {
return new Mastra({
storage: pStore,
agents: {
postiz: await this._loadToolsService.agent(),
},
logger: new ConsoleLogger({
level: 'info',
}),
});
}
}

View File

@ -0,0 +1,5 @@
import { PostgresStore } from '@mastra/pg';
export const pStore = new PostgresStore({
connectionString: process.env.DATABASE_URL,
});

View File

@ -0,0 +1,143 @@
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 { 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';
@Injectable()
export class IntegrationSchedulePostTool implements AgentToolInterface {
constructor(
private _postsService: PostsService,
private _integrationService: IntegrationService
) {}
name = 'integrationSchedulePostTool';
async run(): Promise<any> {
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
`,
requireApproval: true,
inputSchema: z.object({
socialPost: z.array(
z.object({
integrationId: z
.string()
.describe('The id of the integration (not internal id)'),
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'),
image: 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.string().describe('Value of the key'),
})
)
.describe(
'This relies on the integrationSchema tool to get the settings [input:settings]'
),
})
).describe('Individual post')
}),
outputSchema: z.object({
output: z.array(
z.object({
id: z.string(),
postId: z.string(),
releaseURL: z.string(),
status: z.string(),
})
),
}),
execute: async ({ runtimeContext, context }) => {
// @ts-ignore
const organizationId = runtimeContext.get('organization') as string;
const finalOutput = [];
for (const post of context.socialPost) {
const integration = await this._integrationService.getIntegrationById(
organizationId,
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.image.map((p) => ({
id: makeId(10),
path: p,
})),
})),
},
],
});
finalOutput.push(...output);
}
return {
output: finalOutput,
};
},
});
}
}

View File

@ -0,0 +1,151 @@
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';
@Injectable()
export class IntegrationTriggerTool implements AgentToolInterface {
constructor(
private _integrationManager: IntegrationManager,
private _integrationService: IntegrationService
) {}
name = 'triggerTool';
async run(): Promise<any> {
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.record(z.string(), z.any()),
}),
execute: async ({ runtimeContext, context }) => {
console.log('triggerTool', context);
// @ts-ignore
const organizationId = runtimeContext.get('organization') as string;
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' };
}
}
},
});
}
}

View File

@ -0,0 +1,100 @@
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';
@Injectable()
export class IntegrationValidationTool implements AgentToolInterface {
constructor(private _integrationManager: IntegrationManager) {}
name = 'integrationSchema';
async run(): Promise<any> {
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({
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 ({ context }) => {
const integration = socialIntegrationList.find(
(p) => p.identifier === context.platform
)!;
if (!integration) {
return {
output: { maxLength: 0, settings: {}, tools: [] },
};
}
const maxLength = integration.maxLength(context.isPremium);
const schemas = !integration.dto
? false
: validationMetadatasToSchemas()[integration.dto.name];
const tools = this._integrationManager.getAllTools();
return {
output: {
maxLength,
settings: !schemas ? 'No additional settings required' : schemas,
tools: tools[integration.identifier],
},
};
},
});
}
}

View File

@ -0,0 +1,9 @@
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';
export const toolList = [
IntegrationValidationTool,
IntegrationTriggerTool,
IntegrationSchedulePostTool,
];

View File

@ -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[];
}

View File

@ -78,6 +78,24 @@ 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) ||
[],
}),
{}
);
}
getAllPlugs() {
return socialIntegrationList
.map((p) => {

View File

@ -148,6 +148,9 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
scopes = ['write:statuses', 'profile', 'write:media'];
editor = 'normal' as const;
maxLength() {
return 300;
}
async customFields() {
return [

View File

@ -8,6 +8,8 @@ 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
@ -16,6 +18,10 @@ export class DevToProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
editor = 'markdown' as const;
scopes = [] as string[];
maxLength() {
return 100000;
}
dto = DevToSettingsDto;
async generateAuthUrl() {
const state = makeId(6);
@ -30,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',
};
}
@ -89,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', {
@ -101,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', {

View File

@ -7,6 +7,8 @@ import {
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
@ -15,6 +17,11 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
editor = 'markdown' as const;
scopes = ['identify', 'guilds'];
maxLength() {
return 1980;
}
dto = DiscordDto;
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const { access_token, expires_in, refresh_token } = await (
await this.fetch('https://discord.com/api/oauth2/token', {
@ -110,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`, {

View File

@ -11,6 +11,8 @@ 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
@ -19,6 +21,10 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
scopes = ['public', 'upload'];
editor = 'normal' as const;
maxLength() {
return 40000;
}
dto = DribbbleDto;
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const { access_token, expires_in } = await (
@ -59,6 +65,7 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
};
}
@Tool({ description: 'Teams list', dataSchema: [] })
async teams(accessToken: string) {
const { teams } = await (
await this.fetch('https://api.dribbble.com/v2/user', {

View File

@ -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';
@ -24,6 +25,10 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
];
override maxConcurrentJob = 3; // Facebook has reasonable rate limits
editor = 'normal' as const;
maxLength() {
return 63206;
}
dto = FacebookDto;
override handleErrors(body: string):
| {
@ -78,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.",
};
}
@ -233,11 +239,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
.map((p: any) => p.permission);
this.checkScopes(this.scopes, permissions);
const {
id,
name,
picture
} = await (
const { id, name, picture } = await (
await fetch(
`https://graph.facebook.com/v20.0/me?fields=id,name,picture&access_token=${access_token}`
)

View File

@ -11,6 +11,8 @@ 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';
const client = new NeynarAPIClient({
apiKey: process.env.NEYNAR_SECRET_KEY || '00000000-000-0000-000-000000000000',
@ -27,6 +29,10 @@ export class FarcasterProvider
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<AuthTokenDetails> {
return {
@ -69,7 +75,7 @@ export class FarcasterProvider
async post(
id: string,
accessToken: string,
postDetails: PostDetails[]
postDetails: PostDetails<FarcasterDto>[]
): Promise<PostResponse[]> {
const ids = [];
const subreddit =
@ -115,6 +121,10 @@ export class FarcasterProvider
return list;
}
@Tool({
description: 'Search channels',
dataSchema: [{ key: 'word', type: 'string', description: 'Search word' }],
})
async subreddits(
accessToken: string,
data: any,

View File

@ -11,6 +11,7 @@ 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
@ -19,6 +20,10 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
scopes = [] as string[];
editor = 'markdown' as const;
maxLength() {
return 10000;
}
dto = HashnodeSettingsDto;
async generateAuthUrl() {
const state = makeId(6);
@ -103,6 +108,7 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider {
return tags.map((tag) => ({ value: tag.objectID, label: tag.name }));
}
@Tool({ description: 'Publications', dataSchema: [] })
async publications(accessToken: string) {
const {
data: {

View File

@ -31,6 +31,10 @@ export class InstagramProvider
];
override maxConcurrentJob = 10;
editor = 'normal' as const;
dto = InstagramDto;
maxLength() {
return 2200;
}
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
return {
@ -50,7 +54,6 @@ export class InstagramProvider
value: string;
}
| undefined {
if (body.indexOf('An unknown error occurred') > -1) {
return {
type: 'retry' as const,
@ -66,7 +69,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:
@ -368,11 +373,7 @@ export class InstagramProvider
.map((p: any) => p.permission);
this.checkScopes(this.scopes, permissions);
const {
id,
name,
picture
} = await (
const { id, name, picture } = await (
await fetch(
`https://graph.facebook.com/v20.0/me?fields=id,name,picture&access_token=${access_token}`
)
@ -507,7 +508,7 @@ export class InstagramProvider
undefined,
'',
0,
true,
true
)
).json();
await timer(30000);
@ -630,44 +631,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(

View File

@ -27,10 +27,18 @@ export class InstagramStandaloneProvider
'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" | "retry"; value: string } | undefined {
public override handleErrors(
body: string
):
| { type: 'refresh-token' | 'bad-body' | 'retry'; value: string }
| undefined {
return instagramProvider.handleErrors(body);
}
@ -41,7 +49,12 @@ export class InstagramStandaloneProvider
)
).json();
const { user_id, name, username, profile_picture_url = '' } = await (
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}`
)

View File

@ -11,6 +11,7 @@ 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
@ -19,6 +20,10 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
scopes = [] as string[];
editor = 'normal' as const;
maxLength() {
return 10000;
}
dto = LemmySettingsDto;
async customFields() {
return [
@ -203,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,

View File

@ -33,7 +33,9 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
override maxConcurrentJob = 2; // LinkedIn has professional posting limits
refreshWait = true;
editor = 'normal' as const;
maxLength() {
return 3000;
}
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const {
access_token: accessToken,

View File

@ -11,6 +11,7 @@ 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
@ -19,6 +20,11 @@ export class ListmonkProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
scopes = [] as string[];
editor = 'html' as const;
dto = ListmonkDto;
maxLength() {
return 100000000;
}
async customFields() {
return [
@ -104,6 +110,7 @@ export class ListmonkProvider extends SocialAbstract implements SocialProvider {
}
}
@Tool({ description: 'List of available lists', dataSchema: [] })
async list(
token: string,
data: any,
@ -130,6 +137,7 @@ export class ListmonkProvider extends SocialAbstract implements SocialProvider {
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,

View File

@ -15,6 +15,9 @@ export class MastodonProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
scopes = ['write:statuses', 'profile', 'write:media'];
editor = 'normal' as const;
maxLength() {
return 500;
}
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
return {

View File

@ -8,6 +8,8 @@ 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
@ -16,6 +18,10 @@ export class MediumProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
scopes = [] as string[];
editor = 'markdown' as const;
dto = MediumSettingsDto;
maxLength() {
return 100000;
}
async generateAuthUrl() {
const state = makeId(6);
@ -80,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`, {

View File

@ -31,6 +31,10 @@ export class NostrProvider extends SocialAbstract implements SocialProvider {
scopes = [] as string[];
editor = 'normal' as const;
maxLength() {
return 100000;
}
async customFields() {
return [
{

View File

@ -12,6 +12,7 @@ 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';
export class PinterestProvider
extends SocialAbstract
@ -28,6 +29,11 @@ export class PinterestProvider
'user_accounts:read',
];
override maxConcurrentJob = 3; // Pinterest has more lenient rate limits
maxLength() {
return 500;
}
dto = PinterestSettingsDto;
editor = 'normal' as const;
@ -146,6 +152,7 @@ export class PinterestProvider
};
}
@Tool({ description: 'List of boards', dataSchema: [] })
async boards(accessToken: string) {
const { items } = await (
await fetch('https://api.pinterest.com/v5/boards', {

View File

@ -12,6 +12,7 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab
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;
@ -23,6 +24,11 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
scopes = ['read', 'identity', 'submit', 'flair'];
editor = 'normal' as const;
dto = RedditSettingsDto;
maxLength() {
return 10000;
}
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const {
@ -319,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 },
@ -367,6 +383,16 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
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, ...all2 },

View File

@ -8,6 +8,8 @@ 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
@ -23,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<AuthTokenDetails> {
return {
refreshToken: '',
@ -100,6 +108,10 @@ export class SlackProvider extends SocialAbstract implements SocialProvider {
};
}
@Tool({
description: 'Get list of channels',
dataSchema: [],
})
async channels(accessToken: string, params: any, id: string) {
const list = await (
await fetch(

View File

@ -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<

View File

@ -26,6 +26,9 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider {
isWeb3 = true;
scopes = [] as string[];
editor = 'html' as const;
maxLength() {
return 4096;
}
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
return {
@ -147,11 +150,7 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider {
for (const message of postDetails) {
let messageId: number | null = null;
const mediaFiles = message.media || [];
const text = striptags(message.message || '', [
'u',
'strong',
'p',
])
const text = striptags(message.message || '', ['u', 'strong', 'p'])
.replace(/<strong>/g, '<b>')
.replace(/<\/strong>/g, '</b>')
.replace(/<p>(.*?)<\/p>/g, '$1\n');

View File

@ -28,6 +28,9 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
override maxConcurrentJob = 2; // Threads has moderate rate limits
editor = 'normal' as const;
maxLength() {
return 500;
}
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const { access_token } = await (
@ -36,12 +39,9 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
)
).json();
const {
id,
name,
username,
picture
} = await this.fetchPageInformation(access_token);
const { id, name, username, picture } = await this.fetchPageInformation(
access_token
);
return {
id,
@ -105,12 +105,9 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
)
).json();
const {
id,
name,
username,
picture,
} = await this.fetchPageInformation(access_token);
const { id, name, username, picture } = await this.fetchPageInformation(
access_token
);
return {
id,

View File

@ -25,8 +25,11 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
'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):
| {

View File

@ -28,6 +28,9 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
];
editor = 'normal' as const;
maxLength() {
return 2048;
}
async refreshToken(refresh: string): Promise<AuthTokenDetails> {
const [oldRefreshToken, device_id] = refresh.split('&&&&');

View File

@ -12,6 +12,7 @@ import { WordpressDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-set
import slugify from 'slugify';
// import FormData from 'form-data';
import axios from 'axios';
import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
export class WordpressProvider
extends SocialAbstract
@ -23,6 +24,10 @@ export class WordpressProvider
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);
@ -115,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;

View File

@ -17,6 +17,7 @@ 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';
export class XProvider extends SocialAbstract implements SocialProvider {
identifier = 'x';
@ -28,6 +29,11 @@ export class XProvider extends SocialAbstract implements SocialProvider {
'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):
| {

View File

@ -52,6 +52,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
identifier = 'youtube';
name = 'YouTube';
isBetweenSteps = false;
dto = YoutubeSettingsDto;
scopes = [
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email',
@ -64,6 +65,9 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
];
editor = 'normal' as const;
maxLength() {
return 5000;
}
override handleErrors(body: string):
| {

View File

@ -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);
};
}

View File

@ -650,7 +650,7 @@ export const GetPromptRequestSchema = RequestSchema.extend({
/**
* Arguments to use for templating the prompt.
*/
arguments: z.optional(z.record(z.string())),
arguments: z.optional(z.record(z.string(), z.unknown())),
}),
});
@ -815,7 +815,7 @@ export const CallToolRequestSchema = RequestSchema.extend({
method: z.literal('tools/call'),
params: BaseRequestParamsSchema.extend({
name: z.string(),
arguments: z.optional(z.record(z.unknown())),
arguments: z.optional(z.record(z.string(), z.unknown())),
}),
});

View File

@ -43,14 +43,16 @@
"test": "jest --coverage --detectOpenHandles --reporters=default --reporters=jest-junit"
},
"dependencies": {
"@ag-ui/mastra": "0.1.0-next.1",
"@ai-sdk/openai": "^2.0.38",
"@atproto/api": "^0.15.15",
"@aws-sdk/client-s3": "^3.787.0",
"@aws-sdk/s3-request-presigner": "^3.787.0",
"@casl/ability": "^6.5.0",
"@copilotkit/react-core": "^1.8.9",
"@copilotkit/react-textarea": "^1.8.9",
"@copilotkit/react-ui": "^1.8.9",
"@copilotkit/runtime": "^1.8.9",
"@copilotkit/react-core": "^1.10.4",
"@copilotkit/react-textarea": "^1.10.4",
"@copilotkit/react-ui": "^1.10.4",
"@copilotkit/runtime": "^1.10.4",
"@hookform/resolvers": "^3.3.4",
"@langchain/community": "^0.3.40",
"@langchain/core": "^0.3.44",
@ -60,6 +62,9 @@
"@mantine/dates": "^5.10.5",
"@mantine/hooks": "^5.10.5",
"@mantine/modals": "^5.10.5",
"@mastra/core": "^0.18.0",
"@mastra/memory": "^0.15.3",
"@mastra/pg": "^0.16.1",
"@modelcontextprotocol/sdk": "^1.9.0",
"@nestjs/cli": "10.0.2",
"@nestjs/common": "^10.0.2",
@ -137,6 +142,7 @@
"chart.js": "^4.4.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"class-validator-jsonschema": "^5.1.0",
"clsx": "^2.1.0",
"concat-stream": "^2.0.0",
"cookie-parser": "^1.4.7",
@ -160,6 +166,7 @@
"json-to-graphql-query": "^2.2.5",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"mastra": "^0.13.2",
"md5": "^2.3.0",
"mime": "^3.0.0",
"mime-types": "^2.1.35",
@ -229,7 +236,7 @@
"ws": "^8.18.0",
"yargs": "^17.7.2",
"yup": "^1.4.0",
"zod": "^3.24.1",
"zod": "^4.1.11",
"zustand": "^5.0.5"
},
"devDependencies": {

File diff suppressed because it is too large Load Diff