diff --git a/.github/workflows/staging-conflicts.yml b/.github/workflows/staging-conflicts.yml new file mode 100644 index 0000000000..3d58f9b53a --- /dev/null +++ b/.github/workflows/staging-conflicts.yml @@ -0,0 +1,188 @@ +name: Resolve staging conflicts + +# Reproduces a staging <- main merge inside the runner. If it conflicts, Claude +# resolves the conflicts and creates the merge commit. Nothing is commented, +# nothing is pushed unless the resolution passes every verification below. +# +# claude-code-action rejects `push` events, so detection runs on a schedule. +# +# Required secrets: +# STAGING_MERGE_KEY private half of a write-enabled deploy key +# CLAUDE_CODE_OAUTH_TOKEN Claude API key, despite the secret name + +on: + schedule: + - cron: "*/10 * * * *" + workflow_dispatch: + +concurrency: + group: staging-conflict-resolve + cancel-in-progress: false + +jobs: + resolve: + if: github.repository == 'gitroomhq/postiz-app' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout staging + uses: actions/checkout@v6 + with: + ref: staging + fetch-depth: 0 + ssh-key: ${{ secrets.STAGING_MERGE_KEY }} + persist-credentials: true + + # Runs before anything billable. If the deploy key cannot push, the job + # dies here at zero cost instead of after a paid resolution. + - name: Configure push credentials + env: + SSH_KEY: ${{ secrets.STAGING_MERGE_KEY }} + run: | + mkdir -p ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/staging_merge + chmod 600 ~/.ssh/staging_merge + ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts + echo "GIT_SSH_COMMAND=ssh -i $HOME/.ssh/staging_merge -o IdentitiesOnly=yes" >> "$GITHUB_ENV" + + # `git push --dry-run` exercises the exact path the real push takes. + # A bare `ssh -T` does not, since GIT_SSH_COMMAND applies only to git. + - name: Preflight push + run: | + git remote set-url origin "git@github.com:${{ github.repository }}.git" + git push --dry-run origin HEAD:staging + echo "push path verified" + + - name: Probe merge + id: probe + run: | + git config user.name "postiz-merge-bot" + git config user.email "bot@postiz.com" + + if git merge --no-commit --no-ff origin/main; then + git merge --abort 2>/dev/null || git reset --hard HEAD + echo "conflicted=false" >> "$GITHUB_OUTPUT" + echo "staging merges cleanly into main, nothing to do" + else + echo "conflicted=true" >> "$GITHUB_OUTPUT" + echo "Conflicted paths:" + git diff --name-only --diff-filter=U + fi + + # CI definitions always come from main, so conflicts under .github/ are + # settled here by taking main's side. This keeps Claude away from them + # and stops this workflow from deadlocking on edits to itself: it was + # added independently on both branches, so git sees add/add and every + # change to it on main conflicts no matter how staging's copy looks. + - name: Take main's CI definitions + if: steps.probe.outputs.conflicted == 'true' + run: | + git diff --name-only -z --diff-filter=U -- .github/ > /tmp/ci_paths + if [ ! -s /tmp/ci_paths ]; then + echo "no conflicts under .github/" + exit 0 + fi + + echo "taking main's copy of:" + tr '\0' '\n' < /tmp/ci_paths + xargs -0 git checkout --theirs -- < /tmp/ci_paths + xargs -0 git add -- < /tmp/ci_paths + + - name: Resolve with Claude + if: steps.probe.outputs.conflicted == 'true' + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + allowed_bots: "github-actions[bot]" + prompt: | + The repository is checked out on `staging`, part-way through + `git merge --no-ff origin/main`, and the merge has conflicts. + + Resolve every conflicted file so the result preserves the intent of + both sides. Then `git add` the resolved paths and create the merge + commit with: + + git commit --no-edit --trailer "Resolved-by: claude-code-action" + + Constraints: + - Change nothing beyond what the conflict resolution requires. + - Do not push, switch branches, create branches, or amend history. + - Do not post comments, open issues, or touch any pull request. + - Never modify anything under `.github/`. Conflicts there are + already resolved and staged for you; leave them exactly as they + are and resolve only the remaining paths. + - If a conflict is ambiguous enough that you would be guessing at + the correct resolution, stop without committing and explain why. + claude_args: | + --model claude-sonnet-5 + --allowedTools "Read,Glob,Grep,Edit,Write,Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git ls-files:*),Bash(git add:*),Bash(git commit:*)" + + - name: Verify resolution + if: steps.probe.outputs.conflicted == 'true' + run: | + if git ls-files -u | grep -q .; then + echo "::error::Unmerged paths remain in the index" + git ls-files -u + exit 1 + fi + + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + echo "::error::Merge was never committed" + exit 1 + fi + + if git grep -nI -e '^<<<<<<< ' -e '^=======$' -e '^>>>>>>> ' HEAD; then + echo "::error::Conflict markers present in the committed tree" + exit 1 + fi + + if [ -n "$(git status --porcelain)" ]; then + echo "::error::Working tree is dirty after the commit" + git status --porcelain + exit 1 + fi + + if ! git merge-base --is-ancestor origin/main HEAD; then + echo "::error::HEAD does not contain origin/main, wrong commit shape" + exit 1 + fi + + # Every .github/ path the merge touched must be either untouched by + # the merge or byte-identical to main's copy, so a resolution can + # never smuggle in a CI change of its own. + for path in $(git diff --name-only origin/staging..HEAD -- .github/); do + if ! git diff --quiet origin/main HEAD -- "$path"; then + echo "::error::$path differs from main's copy, refusing" + git diff origin/main HEAD -- "$path" + exit 1 + fi + done + + echo "Resolution commit:" + git log -1 --stat + + # Saved before the push, so a push failure never costs a second run. + # Recover with: git fetch ./resolved.bundle HEAD + # git push origin FETCH_HEAD:staging + - name: Archive resolution + if: steps.probe.outputs.conflicted == 'true' + run: git bundle create /tmp/resolved.bundle HEAD ^origin/staging ^origin/main + + - uses: actions/upload-artifact@v4 + if: steps.probe.outputs.conflicted == 'true' + with: + name: staging-resolution-${{ github.run_id }} + path: /tmp/resolved.bundle + retention-days: 14 + + # The action rewrites `origin` to an HTTPS URL during its own git setup, + # so the SSH remote is reasserted here. + - name: Push staging + if: steps.probe.outputs.conflicted == 'true' + run: | + git remote set-url origin "git@github.com:${{ github.repository }}.git" + git push origin HEAD:staging \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f464803c2..01df91e8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **MCP Client Icons & Onboarding Enhancements (Upstream Sync)**: + - Added Nanoclaw and other third-party MCP client icons support in Public API. + - Upgraded onboarding experience and interactive modal walkthroughs. +- **Post Workflow v1.1.2**: + - Enhanced background workflow with automatic retry on heartbeat timeouts when no heartbeat details are present. +- **Frontend & Media Modernization Roadmap**: + - Expanded `ROADMAP.md` with Crove OS visual design standards, workspace switcher overhaul, and R2 direct upload pipeline. + ## [v2.24.0] - 2026-09-03 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 3e2684f5a8..9525505ad3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,21 @@ -# Crove Roadmap +# Crove Post Roadmap -## Provider readiness +## 1. Frontend & UI/UX Modernization (Crove OS Standards) + +- [ ] **Design System & Visual Refresh**: + - Migrate legacy Postiz purple/neon styles to the unified Crove OS Design System (modern dark mode, refined zinc neutrals, subtle glassmorphism). + - Standardize UI components with Tailwind and native primitives across Navigation, Modals, Forms, and Buttons. +- [ ] **Workspace & Organization Switcher Overhaul**: + - Replace stock dropdown with a sleek, multi-tenant Workspace Selector featuring avatar/initials, active checkmarks, and Super-Admin/Role badges. + - Optimize SWR cache invalidation for seamless zero-reload workspace switching. +- [ ] **Post Composer & Media Preview Rework**: + - Redesign the post creation modal with live multi-channel previews (X, LinkedIn, Facebook, Instagram, TikTok, Threads). + - Modernize character counters, hashtag generators, and AI assistant side panels. +- [ ] **Calendar & Analytics Experience**: + - Implement a modern responsive calendar grid with smooth drag-and-drop post scheduling. + - Redesign analytics dashboards with clean charts, engagement heatmaps, and exportable reports. + +## 2. Provider Readiness & Integrations ### TikTok Content Posting API @@ -11,3 +26,16 @@ - [ ] Resolve or document the stock Postiz defaults that preselect public visibility and enable comments before submitting the TikTok audit. - [ ] Submit the Content Posting API audit only after the recorded behavior matches the requested products and scopes. +## 3. Media & Storage Architecture + +- [ ] **Cloudflare R2 Direct Upload & Streaming**: + - Optimize multipart chunked uploads for large video files (Reels, TikTok, YouTube Shorts). + - Implement client-side video transcode checks and automatic thumbnail generation via Cloudflare CDN. + +## 4. AI & Ecosystem Intelligence + +- [ ] **Brand Voice & Copilot Enhancements**: + - Integrate brand voice guidelines and tone-of-voice presets into the OpenAI-compatible AI Copilot engine. + - Expand Mastra / MCP agent capabilities for autonomous multi-channel campaign scheduling. + + diff --git a/apps/frontend/public/icons/third-party/nanoclaw.png b/apps/frontend/public/icons/third-party/nanoclaw.png new file mode 100644 index 0000000000..94d7280cc3 Binary files /dev/null and b/apps/frontend/public/icons/third-party/nanoclaw.png differ diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx index 6076915922..2f4bb8db06 100644 --- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx +++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { FC, useCallback, useMemo, useState } from 'react'; +import React, { FC, Fragment, useCallback, useMemo, useState } from 'react'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import useSWR from 'swr'; import { orderBy } from 'lodash'; @@ -9,6 +9,20 @@ import SafeImage from '@gitroom/react/helpers/safe.image'; import { AddProviderComponent } from '@gitroom/frontend/components/launches/add.provider.component'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useModals } from '@gitroom/frontend/components/layout/new-modal'; +import { useUser } from '@gitroom/frontend/components/layout/user.context'; +import { useVariables } from '@gitroom/react/helpers/variable.context'; +import { + AnyMcpClient, + CopyButton, + getMcpConfig, + getMcpOauthUrl, + isChatOnlyMcpClient, + localCliSteps, + McpAuth, + McpClient, + mcpClients, +} from '@gitroom/frontend/components/public-api/public.component'; +import { McpClientIcon } from '@gitroom/frontend/components/public-api/mcp.client.icons'; interface OnboardingModalProps { onClose: () => void; @@ -19,11 +33,18 @@ export const OnboardingModal: FC = ({ onClose }) => { const modals = useModals(); const t = useT(); + const steps = useMemo( + () => [ + t('connect_channels', 'Connect Channels'), + t('connect_agents', 'Connect Agents'), + t('watch_tutorial', 'Watch Tutorial'), + ], + [t] + ); + return ( -
- +
+
-
+
{/* Step indicators */}
-
-
- 1 -
- - {t('connect_channels', 'Connect Channels')} - -
-
-
-
( + + {index > 0 && ( +
)} - > - 2 -
- - {t('watch_tutorial', 'Watch Tutorial')} - -
+
+
+ {index + 1} +
+ + {label} + +
+ + ))}
{/* Step content */} @@ -100,7 +107,13 @@ export const OnboardingModal: FC = ({ onClose }) => { /> )} {step === 2 && ( - setStep(1)} onFinish={onClose} /> + setStep(1)} + onNext={() => setStep(3)} + /> + )} + {step === 3 && ( + setStep(2)} onFinish={onClose} /> )}
@@ -240,7 +253,439 @@ const OnboardingStep1: FC<{ onNext: () => void; onSkip: () => void }> = ({ ); }; -const OnboardingStep2: FC<{ onBack: () => void; onFinish: () => void }> = ({ +const onboardingAgents = [ + 'Claude', + 'ChatGPT', + 'Claude Code', + 'Cursor', + 'Codex', + 'Grok Bot', +] as const; + +type OnboardingAgent = (typeof onboardingAgents)[number]; + +// Every other MCP client, grouped under one tab with its own picker +const otherTab = 'Other agents' as const; +const otherAgents = mcpClients.filter( + (client) => !(onboardingAgents as readonly string[]).includes(client) +); + +// Not an agent, a tab showing the raw API key for people integrating by hand +const apiTab = 'API' as const; +type OnboardingTab = OnboardingAgent | typeof otherTab | typeof apiTab; + +const cliCommands = localCliSteps.map((step) => step.code); + +// Cursor one-click install: https://cursor.com/docs/mcp/install-links +const getCursorInstallUrl = ( + auth: McpAuth, + mcpBase: string, + apiKey: string +) => { + const server = + auth === 'oauth' + ? { url: getMcpOauthUrl(mcpBase) } + : { + url: `${mcpBase}/mcp`, + headers: { Authorization: `Bearer ${apiKey}` }, + }; + return `cursor://anysphere.cursor-deeplink/mcp/install?name=postiz&config=${btoa( + JSON.stringify(server) + )}`; +}; + +const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({ + onBack, + onNext, +}) => { + const t = useT(); + const user = useUser(); + const { backendUrl, mcpUrl, billingEnabled } = useVariables(); + const [tab, setTab] = useState('Claude'); + const [otherAgent, setOtherAgent] = useState(otherAgents[0]); + // The client the cards describe: the tab itself, or the pick inside "Other agents" + const agent: AnyMcpClient | typeof apiTab = + tab === otherTab ? otherAgent : tab; + const [auth, setAuth] = useState('oauth'); + const [revealed, setRevealed] = useState(false); + const mcpBase = mcpUrl || backendUrl; + const apiKey = user?.publicApi || ''; + const available = !!apiKey && !!user?.tier?.public_api; + + const { config, hint } = + agent === apiTab + ? { config: '', hint: '' } + : getMcpConfig(agent, auth, mcpBase, apiKey); + + const maskedConfig = + revealed || auth === 'oauth' || !apiKey + ? config + : config.replace( + new RegExp(apiKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + '*'.repeat(apiKey.length) + ); + + const connector = + agent === 'Claude' && billingEnabled + ? { + href: 'https://claude.ai/directory/postiz', + label: t('add_to_claude', 'Add to Claude'), + } + : agent === 'Cursor' + ? { + href: getCursorInstallUrl(auth, mcpBase, apiKey), + label: t('add_to_cursor', 'Add to Cursor'), + } + : null; + + const maskedApiKey = revealed ? apiKey : '*'.repeat(apiKey.length); + + const chatSection = ( +
+
+
{t('chat', 'Chat')}
+
+ {t( + 'chat_onboarding_description', + 'No MCP or CLI settings needed. Paste this into the chat, the agent installs the Postiz CLI and asks you for your API key.' + )} +
+
+
+
+
+            {config}
+          
+
+ +
+
+
+
+ {t('api_key', 'API Key')} +
+
+            {maskedApiKey}
+          
+
+ + +
+
+
+
+ ); + + const apiSection = ( + <> +
+
+
+ {t('documentation', 'Documentation')} +
+
+ {t( + 'api_onboarding_description', + 'Use the Postiz API from your own code, n8n or any other automation' + )} +
+
+ + + {t('read_the_api_docs', 'Read the API docs')} + +
+
+
+
+ {t('api_key', 'API Key')} +
+
+ {t( + 'api_key_onboarding_description', + 'Send it as the Authorization header on every request' + )} +
+
+
+
+            {maskedApiKey}
+          
+
+ + +
+
+
+ + ); + + const connectorSection = connector && ( +
+
+
+ {t('connector', 'Connector')} +
+
+ {t( + 'connector_onboarding_description', + 'The fastest way: add Postiz with one click, you will be asked to sign in' + )} +
+
+ + + {connector.label} + +
+ ); + + const mcpSection = ( +
+
+
{t('mcp', 'MCP')}
+
+ {t( + 'mcp_onboarding_description', + 'Give your agent Postiz tools to create, schedule and manage posts' + )} +
+
+
+
+
+ {t('auth_method', 'Authentication')} +
+
+ {(['oauth', 'apikey'] as const).map((m) => ( + + ))} +
+
+
+
+ {hint} + {auth === 'oauth' && + ` ${t( + 'oauth_sign_in_hint', + 'Your agent will open a browser window to sign in to Postiz.' + )}`} +
+
+            {maskedConfig}
+          
+
+ {auth === 'apikey' && ( + + )} + +
+
+
+
+ ); + + const cliSection = ( +
+
+
{t('cli', 'CLI')}
+
+ {t( + 'cli_onboarding_description', + 'Install the Postiz CLI and the skill that teaches your agent how to use it' + )} +
+
+
+
+          {cliCommands.join('\n')}
+        
+
+ +
+
+
+ ); + + return ( +
+
+
+ {t('connect_your_ai_agent', 'Connect Your AI Agent')} +
+
+ {t( + 'connect_agent_description', + 'Pick the agent you use and let it create and schedule posts for you' + )} +
+
+ + {available ? ( +
+
+ {[...onboardingAgents, otherTab, apiTab].map((item) => ( + + ))} +
+ {tab === otherTab && ( +
+ {otherAgents.map((item) => ( + + ))} +
+ )} + + {agent === apiTab ? ( + apiSection + ) : isChatOnlyMcpClient(agent) ? ( + chatSection + ) : ( + <> + {connectorSection} +
+ {mcpSection} + {cliSection} +
+ + )} +
+ ) : ( +
+ {t( + 'agent_access_unavailable', + 'Agent access is not available for your current plan or role. You can set it up later under Settings > Developers.' + )} +
+ )} + + {/* Action buttons */} +
+ +
+ {t( + 'agent_settings_later', + 'More agents and full instructions are available under Settings > Developers' + )} +
+ +
+
+ ); +}; + +const OnboardingStep3: FC<{ onBack: () => void; onFinish: () => void }> = ({ onBack, onFinish, }) => { diff --git a/apps/frontend/src/components/public-api/mcp.client.icons.tsx b/apps/frontend/src/components/public-api/mcp.client.icons.tsx new file mode 100644 index 0000000000..412cfa0c0d --- /dev/null +++ b/apps/frontend/src/components/public-api/mcp.client.icons.tsx @@ -0,0 +1,541 @@ +'use client'; + +import { FC } from 'react'; + +// Logos for the MCP clients shown in Settings > Developers and in onboarding. +// Monochrome marks use currentColor so they follow the button text color, +// brand marks (Claude, VS Code, Gemini) keep their colors. +const icons: Record> = { + Claude: ({ size }) => ( + + + + ), + 'Claude Code': ({ size }) => ( + + + + ), + ChatGPT: ({ size }) => ( + + + + ), + 'Grok Bot': ({ size }) => ( + + + + + + + + + ), + Codex: ({ size }) => ( + + + + ), + Cursor: ({ size }) => ( + + + + ), + 'VS Code / Copilot': ({ size }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + Windsurf: ({ size }) => ( + + + + ), + 'Gemini CLI': ({ size }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + Warp: ({ size }) => ( + + + + ), + Amp: ({ size }) => ( + + + + + + + ), + // Raster mark, served from /public like the other third-party icons + NanoClaw: ({ size }) => ( + NanoClaw + ), + Hermes: ({ size }) => ( + + + + + ), + OpenClaw: ({ size }) => ( + + + + + + + + + + + + + + + + + + ), + // Not a client: the onboarding "Other agents" tab + 'Other agents': ({ size }) => ( + + + + + + + + + + + + ), + // Not a client: the onboarding "API" tab + API: ({ size }) => ( + + + + + ), +}; + +export const McpClientIcon: FC<{ client: string; size?: number }> = ({ + client, + size = 16, +}) => { + const Icon = icons[client]; + if (!Icon) { + return null; + } + return ; +}; diff --git a/apps/frontend/src/components/public-api/public.component.tsx b/apps/frontend/src/components/public-api/public.component.tsx index de8a72deab..24afb92bcc 100644 --- a/apps/frontend/src/components/public-api/public.component.tsx +++ b/apps/frontend/src/components/public-api/public.component.tsx @@ -10,79 +10,144 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { useDecisionModal } from '@gitroom/frontend/components/layout/new-modal'; import { DeveloperComponent } from '@gitroom/frontend/components/developer/developer.component'; +import { McpClientIcon } from '@gitroom/frontend/components/public-api/mcp.client.icons'; import clsx from 'clsx'; -const mcpClients = [ +// Remote clients can't set headers, they get a URL to paste (hint = where) +export const remoteMcpClients = { + Claude: + 'In Claude go to Settings > Connectors > Add custom connector and paste this URL.', + ChatGPT: + 'In ChatGPT go to Settings > Connectors > Create and paste this URL.', +} as const; + +// Clients with no MCP or CLI settings: you paste instructions into the chat, +// the agent installs the CLI itself and asks you for the API key +export const chatOnlyMcpClients = { + 'Grok Bot': + 'Install the Postiz CLI with `npm install -g postiz`, then install the Postiz skill with `npx skills add gitroomhq/postiz-agent`. Ask me for my Postiz API key and set it as the POSTIZ_API_KEY environment variable before using the CLI.', +} as const; + +export const mcpClients = [ + 'OpenClaw', + 'Hermes', + 'NanoClaw', 'Claude Code', 'Cursor', + 'Codex', 'VS Code / Copilot', 'Windsurf', 'Amp', - 'Codex', 'Gemini CLI', 'Warp', ] as const; -type McpClient = (typeof mcpClients)[number]; +export type RemoteMcpClient = keyof typeof remoteMcpClients; +export type ChatOnlyMcpClient = keyof typeof chatOnlyMcpClients; +export type McpClient = (typeof mcpClients)[number]; +export type AnyMcpClient = RemoteMcpClient | ChatOnlyMcpClient | McpClient; + +// oauth: no API key, the client registers itself (DCR) and the user signs in to Postiz +// apikey: the organization API key, as a Bearer header (or inside the URL for remote clients) +export type McpAuth = 'oauth' | 'apikey'; + +export const getMcpOauthUrl = (mcpBase: string) => + `${mcpBase}/mcp-oauth-dynamic`; -const getMcpConfig = ( - client: McpClient, - method: 'header' | 'path', +export const isRemoteMcpClient = (client: string): client is RemoteMcpClient => + client in remoteMcpClients; + +export const isChatOnlyMcpClient = ( + client: string +): client is ChatOnlyMcpClient => client in chatOnlyMcpClients; + +export const getMcpConfig = ( + client: AnyMcpClient, + auth: McpAuth, mcpBase: string, apiKey: string ): { config: string; hint: string } => { - const urlWithKey = `${mcpBase}/mcp/${apiKey}`; + if (isChatOnlyMcpClient(client)) { + return { + config: chatOnlyMcpClients[client], + hint: 'Paste this into the chat. The agent will ask you for your API key.', + }; + } + if (isRemoteMcpClient(client)) { + return { + config: + auth === 'oauth' ? getMcpOauthUrl(mcpBase) : `${mcpBase}/mcp/${apiKey}`, + hint: remoteMcpClients[client], + }; + } + + const oauthUrl = getMcpOauthUrl(mcpBase); const urlBase = `${mcpBase}/mcp`; const bearer = `Bearer ${apiKey}`; const json = (obj: object) => JSON.stringify(obj, null, 2); - if (method === 'path') { + if (auth === 'oauth') { switch (client) { case 'Claude Code': return { - config: `claude mcp add postiz --transport http "${urlWithKey}"`, + config: `claude mcp add postiz --transport http "${oauthUrl}"`, hint: 'Run this command in your terminal.', }; case 'Cursor': return { - config: json({ mcpServers: { postiz: { url: urlWithKey } } }), + config: json({ mcpServers: { postiz: { url: oauthUrl } } }), hint: 'Add to .cursor/mcp.json in your project root.', }; case 'VS Code / Copilot': return { config: json({ - servers: { postiz: { type: 'http', url: urlWithKey } }, + servers: { postiz: { type: 'http', url: oauthUrl } }, }), hint: 'Add to .vscode/mcp.json in your project root.', }; case 'Windsurf': return { config: json({ - mcpServers: { postiz: { serverUrl: urlWithKey } }, + mcpServers: { postiz: { serverUrl: oauthUrl } }, }), hint: 'Add to ~/.codeium/windsurf/mcp_config.json', }; case 'Amp': return { - config: `amp mcp add postiz ${urlWithKey}`, + config: `amp mcp add postiz ${oauthUrl}`, hint: 'Run this command in your terminal.', }; case 'Codex': return { - config: `# ~/.codex/config.toml\n\n[mcp_servers.postiz]\nurl = "${urlWithKey}"`, - hint: 'Add to ~/.codex/config.toml', + config: `# ~/.codex/config.toml\n\n[mcp_servers.postiz]\nurl = "${oauthUrl}"`, + hint: 'Add to ~/.codex/config.toml, then run: codex mcp login postiz', }; case 'Gemini CLI': return { - config: json({ mcpServers: { postiz: { url: urlWithKey } } }), + config: json({ mcpServers: { postiz: { url: oauthUrl } } }), hint: 'Add to ~/.gemini/settings.json', }; case 'Warp': return { - config: json({ postiz: { url: urlWithKey } }), + config: json({ postiz: { url: oauthUrl } }), hint: 'Settings > MCP Servers > + Add, then paste this config.', }; + case 'Hermes': + return { + config: `# ~/.hermes/config.yaml\n\nmcp_servers:\n postiz:\n url: "${oauthUrl}"\n auth: oauth`, + hint: 'Add to ~/.hermes/config.yaml, then run /reload-mcp in the chat.', + }; + case 'OpenClaw': + return { + config: `openclaw mcp add postiz --url ${oauthUrl} --transport streamable-http --auth oauth && openclaw mcp login postiz`, + hint: 'Run this command in your terminal.', + }; + case 'NanoClaw': + return { + config: `ncl groups config add-mcp-server --id --name postiz --url ${oauthUrl}`, + hint: 'Run this in your terminal, replace with the agent group that should get Postiz.', + }; } } @@ -156,10 +221,36 @@ const getMcpConfig = ( }), hint: 'Settings > MCP Servers > + Add, then paste this config.', }; + case 'Hermes': + return { + config: `# ~/.hermes/config.yaml\n\nmcp_servers:\n postiz:\n url: "${urlBase}"\n headers:\n Authorization: "${bearer}"`, + hint: 'Add to ~/.hermes/config.yaml, then run /reload-mcp in the chat.', + }; + case 'OpenClaw': + return { + config: json({ + mcp: { + servers: { + postiz: { + url: urlBase, + transport: 'streamable-http', + headers: { Authorization: bearer }, + }, + }, + }, + }), + hint: 'Add to ~/.openclaw/openclaw.json', + }; + case 'NanoClaw': + // No headers flag, the key travels inside the URL like remote clients + return { + config: `ncl groups config add-mcp-server --id --name postiz --url ${mcpBase}/mcp/${apiKey}`, + hint: 'Run this in your terminal, replace with the agent group that should get Postiz.', + }; } }; -const CopyButton = ({ +export const CopyButton = ({ text, label, }: { @@ -203,27 +294,28 @@ const McpSection = ({ }) => { const t = useT(); const { billingEnabled } = useVariables(); - const [activeClient, setActiveClient] = useState('Claude Code'); - const [method, setMethod] = useState<'header' | 'path'>('header'); + const [activeClient, setActiveClient] = useState('Claude'); + const [auth, setAuth] = useState('oauth'); const [revealed, setRevealed] = useState(false); const { config, hint } = getMcpConfig( activeClient, - method, + auth, mcpBase, user.publicApi ); - const remoteUrl = `${mcpBase}/mcp/${user.publicApi}`; - const cliUrl = `${mcpBase}/mcp`; + const baseUrl = auth === 'oauth' ? getMcpOauthUrl(mcpBase) : `${mcpBase}/mcp`; - const maskedConfig = revealed - ? config - : config.replace(new RegExp(user.publicApi.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '*'.repeat(user.publicApi.length)); + const chatOnly = isChatOnlyMcpClient(activeClient); - const maskedRemoteUrl = revealed - ? remoteUrl - : remoteUrl.replace(user.publicApi, '*'.repeat(user.publicApi.length)); + const maskedConfig = + revealed || auth === 'oauth' || chatOnly + ? config + : config.replace( + new RegExp(user.publicApi.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + '*'.repeat(user.publicApi.length) + ); return (
@@ -261,108 +353,112 @@ const McpSection = ({
-
-
- {t('auth_method', 'Authentication')} -
-
- {(['header', 'path'] as const).map((m) => ( - - ))} -
-
- {method === 'header' && ( + {!chatOnly && (
- {t('mcp_client', 'Client')} + {t('auth_method', 'Authentication')}
-
- {mcpClients.map((client) => ( +
+ {(['oauth', 'apikey'] as const).map((m) => ( ))}
)} +
+
+ {t('mcp_client', 'Client')} +
+
+ {[ + ...Object.keys(remoteMcpClients), + ...mcpClients, + ...Object.keys(chatOnlyMcpClients), + ].map((client) => ( + + ))} +
+
- {method === 'header' - ? hint - : t( - 'remote_server_url_hint', - 'Paste this URL into your remote MCP client (ChatGPT, Claude, etc.).' - )} + {hint} + {auth === 'oauth' && + !chatOnly && + ` ${t( + 'oauth_sign_in_hint', + 'Your agent will open a browser window to sign in to Postiz.' + )}`}
-            {method === 'header' ? maskedConfig : maskedRemoteUrl}
+            {maskedConfig}
           
- - - {method === 'header' && ( - + + {revealed ? ( + <> + + + + + ) : ( + <> + + + + )} + + {revealed ? t('hide', 'Hide') : t('reveal', 'Reveal')} + + )} + + {!isRemoteMcpClient(activeClient) && !chatOnly && ( + )} - {method === 'path' && billingEnabled && ( + {activeClient === 'Claude' && billingEnabled && ( { + return proxyActivities({ + startToCloseTimeout: '10 minute', + taskQueue, + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '2 minutes', + }, + }); +}; + +// postComment publishes through providers that can legitimately run long +// (media conversion + upload), so it gets a large time budget. The +// heartbeatTimeout exists to detect an activity that was never started: the +// activity heartbeats every 15s, so no heartbeat at all means the worker never +// ran it and nothing was published. No SDK retries: an +// automatic retry of a heartbeat timeout would run again even when the first +// attempt did publish, duplicating the comment. The workflow decides whether +// a failure is safe to retry (see handleActivityError). +const proxyCommentTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '30 minute', + heartbeatTimeout: HEARTBEAT_TIMEOUT, + taskQueue, + retry: { + maximumAttempts: 1, + }, + }); +}; + +// checkPostStatus is a single read-only status call, so it gets a short timeout +// and fast retries - retrying it can never duplicate a post. +const proxyCheckTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '2 minute', + taskQueue, + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '10 seconds', + }, + }); +}; + +// postSocialPending / finalizePost run irreversible publishing mutations, so no +// automatic retries - a retried activity whose previous (timed-out) attempt +// still completed in the background would publish twice. The workflow retries +// deliberately, and treats timeouts as "outcome unknown". +// The heartbeatTimeout exists to detect an activity that was never started: +// both activities heartbeat every 15s, so no heartbeat at all means the +// worker never ran them and nothing was published. The workflow +// decides whether that is safe to retry (see handleActivityError); every +// other timeout still marks the post as unconfirmed. +const proxyMutationTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '30 minute', + heartbeatTimeout: HEARTBEAT_TIMEOUT, + taskQueue, + retry: { + maximumAttempts: 1, + }, + }); +}; + +const { + getPostsList, + getPost, + inAppNotification, + changeState, + updatePost, + sendWebhooks, + isCommentable, +} = proxyActivities({ + startToCloseTimeout: '10 minute', + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '2 minutes', + }, +}); + +const poke = defineSignal('poke'); + +const iterate = Array.from({ length: 5 }); + +// ~30 minutes at 20s interval (longer than the old in-activity loop, timers are +// free). Multi-item flows (stories, chunked uploads) consume several checks per +// item, so the budget must cover the largest realistic post, not one poll cycle. +const maxPendingChecks = 90; + +export async function postWorkflowV112({ + taskQueue, + postId, + organizationId, + postNow = false, +}: { + taskQueue: string; + postId: string; + organizationId: string; + postNow?: boolean; +}) { + // Dynamic task queue, for concurrency + const { + getIntegrationById, + refreshTokenWithCause, + internalPlugs, + globalPlugs, + processInternalPlug, + processPlug, + } = proxyTaskQueue(taskQueue); + + const { checkPostStatus } = proxyCheckTaskQueue(taskQueue); + + const { postComment } = proxyCommentTaskQueue(taskQueue); + + const { postSocialPending, finalizePost } = proxyMutationTaskQueue(taskQueue); + + let poked = false; + setHandler(poke, () => { + poked = true; + }); + + // get all the posts and comments to post + const firstPost = await getPost(organizationId, postId); + + // in case doesn't exists for some reason, fail it + if (!firstPost) { + await changeState(postId, 'ERROR', 'No Post'); + return; + } + + if (!postNow && firstPost.state !== 'QUEUE') { + await changeState(firstPost.id, 'ERROR', 'Already posted', [firstPost]); + return; + } + + // wait for the scheduled publish date + if (!postNow) { + await sleep( + dayjs(firstPost.publishDate).isBefore(dayjs()) + ? 0 + : dayjs(firstPost.publishDate).diff(dayjs(), 'millisecond') + ); + } + + // Captured AFTER the scheduling sleep: the repeat-post delay is + // "interval minus time spent publishing", so it must be measured from the + // publish time, not from when the workflow was started. Measuring from the + // workflow start subtracted the whole scheduling wait from the interval + // (a post scheduled further out than its interval repeated immediately). + const startTime = new Date(); + + const postsListBefore = await getPostsList(organizationId, postId); + const [post] = postsListBefore; + + if (!post) { + await changeState(postId, 'ERROR', 'No Post'); + return; + } + + // if refresh is needed from last time, let's inform the user + if (post.integration?.refreshNeeded) { + await inAppNotification( + post.organizationId, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because you need to reconnect it. Please enable it and try again.`, + true, + false, + 'info' + ); + + await changeState( + postsListBefore[0].id, + 'ERROR', + 'Refresh channel needed', + postsListBefore + ); + return; + } + + // if it's disabled, inform the user + if (post.integration?.disabled) { + await inAppNotification( + post.organizationId, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because it's disabled. Please enable it and try again.`, + true, + false, + 'info' + ); + + await changeState( + postsListBefore[0].id, + 'ERROR', + 'Channel disabled', + postsListBefore + ); + return; + } + + // Do we need to post comment for this social? + const toComment: boolean = + postsListBefore.length === 1 + ? false + : await isCommentable(post.integration); + + const postsList = toComment ? postsListBefore : [postsListBefore[0]]; + + // list of all the saved results + const postsResults: PostResponse[] = []; + + // Every catch block below used to repeat the same failure classification, so + // it is centralized here: detect the failure type, refresh the token when + // needed, and tell the caller what to do. + // 'retry' - the token was refreshed, or the activity never started (heartbeat + // timeout with no heartbeat), run the action again + // 'stop' - the token could not be refreshed + // 'bad-body' - the platform rejected the action + // 'timeout' - the activity timed out, its outcome is unknown + // 'unknown' - anything else (transient errors) + const handleActivityError = async ( + err: unknown, + getIntegration?: () => Promise, + heartbeats?: boolean + ): Promise<{ + type: 'retry' | 'stop' | 'bad-body' | 'timeout' | 'unknown'; + message: string; + }> => { + if ( + err instanceof ActivityFailure && + err.cause instanceof TimeoutFailure + ) { + // The server copies the last heartbeat details it received into the + // timeout failure. None at all (undefined) means it never received a + // heartbeat: the worker never ran the activity, so nothing was published + // and it is safe to run again. Any details mean the activity ran and + // then stalled, so its outcome is unknown. This relies on withHeartbeat + // always sending a non-empty string (": entered" at least): + // a heartbeat sent with undefined details also leaves the failure + // without details. Only the callers of heartbeating activities opt in; + // no time window, so activity dispatch delay cannot skew the decision. + if ( + heartbeats && + err.cause.timeoutType === TimeoutType.HEARTBEAT && + !err.cause.lastHeartbeatDetails + ) { + return { type: 'retry', message: '' }; + } + return { type: 'timeout', message: '' }; + } + + const cause = + err instanceof ActivityFailure && err.cause instanceof ApplicationFailure + ? err.cause + : undefined; + + if (cause?.type === 'refresh_token') { + const refresh = await refreshTokenWithCause( + getIntegration ? await getIntegration() : post.integration, + cause.message || '' + ); + if (!refresh || !refresh.accessToken) { + return { type: 'stop', message: cause.message || '' }; + } + + if (!getIntegration) { + post.integration.token = refresh.accessToken; + } + + return { type: 'retry', message: cause.message || '' }; + } + + if (cause?.type === 'bad_body') { + return { type: 'bad-body', message: cause.message || '' }; + } + + return { type: 'unknown', message: '' }; + }; + + // The platform may have accepted the post but we can't confirm it was + // published - mark the error with a distinct message so the user checks the + // account before reposting manually and duplicating it. + const markUnconfirmed = async (err: any) => { + await changeState(postsList[0].id, 'ERROR', err, postsList); + await inAppNotification( + post.organizationId, + `We couldn't confirm your post on ${capitalize( + post.integration?.providerIdentifier + )}`, + `Your post was sent to ${capitalize( + post.integration?.providerIdentifier + )}, but we couldn't confirm it was published. Please check your ${ + post?.integration?.name + } account before posting again to avoid duplicates.`, + true, + false, + 'fail' + ); + }; + + // The post/comment was already accepted by the platform but returned as + // "pending": poll the read-only status check with durable timers until it + // completes. Errors are fully handled here (never rethrown), otherwise they + // would bubble to the posting retry loop and re-run the publish. + const resolvePending = async ( + pending: PostResponse + ): Promise => { + let pendingData = pending.pendingData; + let errorAttempts = 0; + let heartbeats = false; + + for (let check = 0; check < maxPendingChecks; check++) { + // only finalizePost heartbeats, so a checkPostStatus failure must never + // be classified as never started + heartbeats = false; + try { + let result = await checkPostStatus(post.integration, pendingData); + + // commit the check's state BEFORE finalizePost runs: if finalize dies + // mid-mutation, the next check must see what it had already authorized, + // so providers can detect the interrupted attempt instead of running + // the mutation again + if (result.status !== 'completed') { + pendingData = result.pendingData; + } + + // polling is done, run the remaining provider mutations + if (result.status === 'ready') { + heartbeats = true; + result = await finalizePost(post.integration, result.pendingData); + } + + if (result.status === 'completed') { + return { + id: pending.id, + postId: result.postId, + releaseURL: result.releaseURL, + status: 'success', + }; + } + + pendingData = result.pendingData; + + // a fully successful iteration proves the platform is reachable: the + // error budget bounds consecutive failures, not blips accumulated over + // a long upload + errorAttempts = 0; + } catch (err) { + const handle = await handleActivityError(err, undefined, heartbeats); + + // token refreshed, or finalize never started, check again right away + if (handle.type === 'retry') { + continue; + } + + // the token could not be refreshed while checking, but the platform + // already accepted the post - warn about a possible live post + if (handle.type === 'stop') { + await markUnconfirmed(err); + return false; + } + + // the platform explicitly failed the post, it was not published + if (handle.type === 'bad-body') { + await changeState(postsList[0].id, 'ERROR', err, postsList); + await inAppNotification( + post.organizationId, + `Error posting on ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `An error occurred while posting on ${ + post.integration?.providerIdentifier + }${handle.message ? `: ${handle.message}` : ``}`, + true, + false, + 'fail' + ); + return false; + } + + // unknown error on a read-only check, retry a few more times + errorAttempts++; + if (errorAttempts >= iterate.length) { + break; + } + } + + // the platform is still processing, wait before the next check + await sleep('20 seconds'); + } + + // no verdict from the platform after all the checks + await markUnconfirmed('Could not confirm the post status'); + return false; + }; + + // iterate over the posts + for (let i = 0; i < postsList.length; i++) { + const before = postsResults.length; + // once the platform accepted the post, the catch below must never retry + // the publish - retrying after updatePost / notification errors would + // duplicate the post + let posted = false; + let updated = false; + // this is a small trick to repeat an action in case of token refresh + for (const _ of iterate) { + // both publish calls run heartbeating activities, but the timed-out + // status checks below must never be mistaken for never-started + let heartbeats = false; + try { + // first post the main post + if (i === 0) { + heartbeats = true; + postsResults.push( + ...(await postSocialPending(post.integration as Integration, [ + postsList[i], + ])) + ); + + // then post the comments if any + } else { + if (postsList[i].delay) { + await sleep(60000 * Math.max(0, Number(postsList[i].delay ?? 0))); + } + + heartbeats = true; + postsResults.push( + ...(await postComment( + postsResults[0].postId, + postsResults.length === 1 + ? undefined + : postsResults[i - 1].postId, + post.integration, + [postsList[i]] + )) + ); + } + + posted = true; + + // the platform accepted the post but is still processing it: resolve + // it here before marking anything, resolvePending handles its own + // errors so a failed status check can never re-run the publish above + if (postsResults[i].status === 'pending') { + let resolved: PostResponse | false = false; + try { + resolved = await resolvePending(postsResults[i]); + } catch (err) { + // never let a pending-resolution error reach the outer catch, it + // would retry the post and duplicate it. Best-effort error state, + // otherwise the post stays in QUEUE and the missing-posts sweep + // would re-publish it. + try { + await markUnconfirmed(err); + } catch (e) { + /**empty**/ + } + resolved = false; + } + if (!resolved) { + return false; + } + postsResults[i] = resolved; + } + + // mark post as successful + await updatePost( + postsList[i].id, + postsResults[i].postId, + postsResults[i].releaseURL + ); + updated = true; + + if (i === 0) { + // send notification on a sucessful post + await inAppNotification( + post.integration.organizationId, + `Your post has been published on ${capitalize( + post.integration.providerIdentifier + )}`, + `Your post has been published on ${capitalize( + post.integration.providerIdentifier + )} at ${postsResults[0].releaseURL}`, + true, + true + ); + } + + // break the current while to move to the next post + break; + } catch (err) { + // the post is already live: never re-run the publish + if (posted) { + if (!updated) { + // still marked QUEUE, record the error so the missing-posts sweep + // doesn't re-publish it + try { + await markUnconfirmed(err); + } catch (e) { + /**empty**/ + } + return false; + } + + // already marked published, a failed notification shouldn't abort + // the rest of the flow + break; + } + + const handle = await handleActivityError(err, undefined, heartbeats); + + // token refreshed, or the publish never started, repeat the action + if (handle.type === 'retry') { + continue; + } + + // the activity timed out: the platform may still complete the publish + // in the background, so never retry it + if (handle.type === 'timeout') { + try { + await markUnconfirmed(err); + } catch (e) { + /**empty**/ + } + return false; + } + + // for other errors, change state and inform the user if needed + await changeState(postsList[0].id, 'ERROR', err, postsList); + + if (handle.type === 'stop') { + return false; + } + + // specific case for bad body errors + if (handle.type === 'bad-body') { + await inAppNotification( + post.organizationId, + `Error posting${i === 0 ? ' ' : ' comments '}on ${ + post.integration?.providerIdentifier + } for ${post?.integration?.name}`, + `An error occurred while posting${i === 0 ? ' ' : ' comments '}on ${ + post.integration?.providerIdentifier + }${handle.message ? `: ${handle.message}` : ``}`, + true, + false, + 'fail' + ); + return false; + } + } + } + + if (postsResults.length === before) { + // all retries exhausted without success: record it, otherwise the post + // stays in QUEUE with no error and the missing-posts sweep re-publishes + // it. A retried publish may have run without reporting, so treat the + // outcome as unknown. + try { + await markUnconfirmed('Could not publish after several attempts'); + } catch (e) { + /**empty**/ + } + return false; + } + } + + // send webhooks for the post + await sendWebhooks( + postsResults[0].postId, + post.organizationId, + post.integration.id + ); + + // load internal plugs like repost by other users + const internalPlugsList = await internalPlugs( + post.integration, + JSON.parse(post.settings) + ); + + // load global plugs, like repost a post if it gets to a certain number of likes + const globalPlugsList = (await globalPlugs(post.integration)).reduce( + (all, current) => { + for (let i = 1; i <= current.totalRuns; i++) { + all.push({ + ...current, + delay: current.delay * i, + }); + } + + return all; + }, + [] + ); + + // Check if the post is repeatable + const repeatPost = !post.intervalInDays + ? [] + : [ + { + type: 'repeat-post', + delay: + post.intervalInDays * 24 * 60 * 60 * 1000 - + (new Date().getTime() - startTime.getTime()), + }, + ]; + + // Sort all the actions by delay, so we can process them in order + const list = sortBy( + [...internalPlugsList, ...globalPlugsList, ...repeatPost], + 'delay' + ); + + // process all the plugs in order, we are using while because in some cases we need to remove items from the list + while (list.length > 0) { + // get the next to process + const todo = list.shift(); + + // wait for the delay + await sleep(Math.max(0, Number(todo.delay ?? 0))); + + // process internal plug + if (todo.type === 'internal-plug') { + for (const _ of iterate) { + try { + await processInternalPlug({ ...todo, post: postsResults[0].postId }); + } catch (err) { + const handle = await handleActivityError(err, () => + getIntegrationById(organizationId, todo.integration) + ); + + if (handle.type === 'stop' || handle.type === 'bad-body') { + break; + } + + continue; + } + break; + } + } + + // process global plug + if (todo.type === 'global') { + for (const _ of iterate) { + try { + const process = await processPlug({ + ...todo, + postId: postsResults[0].postId, + }); + if (process) { + const toDelete = list + .reduce((all, current, index) => { + if (current.plugId === todo.plugId) { + all.push(index); + } + + return all; + }, []) + .reverse(); + + for (const index of toDelete) { + list.splice(index, 1); + } + } + } catch (err) { + const handle = await handleActivityError(err); + + if (handle.type === 'stop' || handle.type === 'bad-body') { + break; + } + + continue; + } + + break; + } + } + + // process repeat post in a new workflow, this is important so the other plugs can keep running + if (todo.type === 'repeat-post') { + await startChild(postWorkflowV112, { + parentClosePolicy: 'ABANDON', + args: [ + { + taskQueue, + postId, + organizationId, + postNow: true, + }, + ], + workflowId: `post_${post.id}_${makeId(10)}`, + typedSearchAttributes: new TypedSearchAttributes([ + { + key: postIdSearchParam, + value: postId, + }, + ]), + }); + } + } +} 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 bc67276986..13e684f813 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -727,7 +727,7 @@ export class PostsService { try { await this._temporalService.client .getRawClient() - ?.workflow.start('postWorkflowV111', { + ?.workflow.start('postWorkflowV112', { workflowId: `post_${postId}`, taskQueue: 'main', workflowIdConflictPolicy: 'TERMINATE_EXISTING', diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index 41d74ef257..2d9252f525 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -696,10 +696,31 @@ "connected_channels": "Connected Channels", "continue": "Continue", "continue_without_channels": "Continue without channels", + "connect_agents": "Connect Agents", + "connect_your_ai_agent": "Connect Your AI Agent", + "connect_agent_description": "Pick the agent you use and let it create and schedule posts for you", + "agent_access_unavailable": "Agent access is not available for your current plan or role. You can set it up later under Settings > Developers.", + "sign_in_no_api_key": "Sign in with Postiz (no API key)", + "oauth_sign_in_hint": "Your agent will open a browser window to sign in to Postiz.", + "add_to_cursor": "Add to Cursor", + "cli": "CLI", + "other_agents": "Other agents", + "documentation": "Documentation", + "read_the_api_docs": "Read the API docs", + "api_key_onboarding_description": "Send it as the Authorization header on every request", + "api_onboarding_description": "Use the Postiz API from your own code, n8n or any other automation", + "chat": "Chat", + "chat_onboarding_description": "No MCP or CLI settings needed. Paste this into the chat, the agent installs the Postiz CLI and asks you for your API key.", + "connector": "Connector", + "connector_onboarding_description": "The fastest way: add Postiz with one click, you will be asked to sign in", + "mcp_onboarding_description": "Give your agent Postiz tools to create, schedule and manage posts", + "cli_onboarding_description": "Install the Postiz CLI and the skill that teaches your agent how to use it", + "agent_settings_later": "More agents and full instructions are available under Settings > Developers", "watch_tutorial": "Watch Tutorial", "watch_tutorial_title": "Learn How to Use Postiz", "watch_tutorial_description": "Watch this short video to learn how to get the most out of Postiz", "back": "Back", + "continue_skip": "Continue / Skip", "get_started": "Get Started", "kick_select_channel": "Select Channel", "annual": "Annual",