diff --git a/apps/builder/__tests__/channel-reconnect-actions.test.ts b/apps/builder/__tests__/channel-reconnect-actions.test.ts index 8fffc12c48..ec681ed2e8 100644 --- a/apps/builder/__tests__/channel-reconnect-actions.test.ts +++ b/apps/builder/__tests__/channel-reconnect-actions.test.ts @@ -25,16 +25,19 @@ const { mockFindMessengerIntegration, mockFindInstagramIntegration, mockFindZaloIntegration, + mockFindThreadsIntegration, mockResolveForOwner, mockRedirect, mockGenerateMessengerAuthUrl, mockGenerateInstagramAuthUrl, mockGenerateInstagramFacebookAuthUrl, mockGenerateZaloAuthUrl, + mockGenerateThreadsAuthUrl, } = vi.hoisted(() => ({ mockFindMessengerIntegration: vi.fn(), mockFindInstagramIntegration: vi.fn(), mockFindZaloIntegration: vi.fn(), + mockFindThreadsIntegration: vi.fn(), mockResolveForOwner: vi.fn(), mockRedirect: vi.fn(), mockGenerateMessengerAuthUrl: vi.fn(() => "https://facebook.example/auth"), @@ -43,6 +46,7 @@ const { () => "https://facebook.example/instagram-auth", ), mockGenerateZaloAuthUrl: vi.fn(() => "https://zalo.example/auth"), + mockGenerateThreadsAuthUrl: vi.fn(() => "https://threads.example/auth"), })) vi.mock("@/lib/safe-action", () => ({ @@ -59,6 +63,9 @@ vi.mock("@chatbotx.io/business", () => ({ zaloIntegrationService: { findById: mockFindZaloIntegration, }, + integrationThreadsService: { + findByIdForWorkspace: mockFindThreadsIntegration, + }, platformCredentialService: { resolveForOwner: mockResolveForOwner, }, @@ -96,10 +103,18 @@ vi.mock("@chatbotx.io/integration-zalo", () => ({ generateAuthUrl: mockGenerateZaloAuthUrl, })) +vi.mock("@chatbotx.io/integration-threads", () => ({ + generateAuthUrl: mockGenerateThreadsAuthUrl, +})) + vi.mock("next/navigation", () => ({ redirect: mockRedirect, })) +vi.mock("next-intl/server", () => ({ + getTranslations: vi.fn(async () => (key: string) => key), +})) + vi.mock("@/lib/domain", () => ({ getOriginUrlFromHeader: vi.fn(async () => "https://app.example.com"), })) @@ -108,6 +123,7 @@ const BROKER_ORIGIN = "https://broker.example.com" vi.mock("@/lib/oauth-broker", () => ({ getBrokerOrigin: () => BROKER_ORIGIN, + buildBrokerCallbackUrl: (path: string) => `${BROKER_ORIGIN}${path}`, })) const { mockFindActiveByTenantId, mockFindByOwner } = vi.hoisted(() => ({ @@ -120,11 +136,13 @@ vi.mock("@/env", () => ({ isCloud: () => true })) await import("../src/features/integration-messenger/actions/reconnect.action") await import("../src/features/integration-instagram/actions/reconnect.action") await import("../src/features/integration-zalo/actions/reconnect.action") +await import("../src/features/integration-threads/actions/reconnect.action") const [ reconnectMessengerHandler, reconnectInstagramHandler, reconnectZaloHandler, + reconnectThreadsHandler, ] = capturedActionHandlers const executeMessengerReconnect = () => @@ -145,6 +163,12 @@ const executeZaloReconnect = () => ctx: { workspace: { id: "ws-1", ownerId: "owner-1" } }, }) +const executeThreadsReconnect = () => + reconnectThreadsHandler({ + bindArgsParsedInputs: ["ws-1", "th-1"], + ctx: { workspace: { id: "ws-1", ownerId: "owner-1" } }, + }) + describe("reconnectMessengerAction", () => { beforeEach(() => { vi.clearAllMocks() @@ -392,3 +416,59 @@ describe("reconnectZaloAction", () => { ) }) }) + +describe("reconnectThreadsAction", () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveForOwner.mockResolvedValue({ + config: { + clientId: "client-1", + clientSecret: "secret-1", + version: "v23.0", + }, + }) + }) + + test("redirects to the Threads dialog with reconnect state", async () => { + mockFindThreadsIntegration.mockResolvedValue({ + id: "th-1", + threadsUserId: "threads-user-1", + }) + + await executeThreadsReconnect() + + expect(mockGenerateThreadsAuthUrl).toHaveBeenCalledWith({ + clientId: "client-1", + redirectUrl: "https://broker.example.com/integrations/threads/callback", + stateParams: { + workspaceId: "ws-1", + referer: + "https://app.example.com/space/ws-1/settings/channels?channel=threads", + reconnectIntegrationId: "th-1", + }, + }) + expect(mockRedirect).toHaveBeenCalledWith("https://threads.example/auth") + }) + + test("throws a translated not-found error when the integration is missing", async () => { + mockFindThreadsIntegration.mockResolvedValue(undefined) + + await expect(executeThreadsReconnect()).rejects.toThrow( + "channels.reconnect.errors.notFound", + ) + expect(mockRedirect).not.toHaveBeenCalled() + }) + + test("throws a translated app-settings error when the credential is missing", async () => { + mockFindThreadsIntegration.mockResolvedValue({ + id: "th-1", + threadsUserId: "threads-user-1", + }) + mockResolveForOwner.mockResolvedValue(null) + + await expect(executeThreadsReconnect()).rejects.toThrow( + "messages.needToAddSettings", + ) + expect(mockRedirect).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/channels-create-platform-owner.test.ts b/apps/builder/__tests__/channels-create-platform-owner.test.ts index c70eef029b..f5723bf0d7 100644 --- a/apps/builder/__tests__/channels-create-platform-owner.test.ts +++ b/apps/builder/__tests__/channels-create-platform-owner.test.ts @@ -148,6 +148,7 @@ describe("channels/create — platform owner fan-out", () => { "instagram", "instagramFacebook", "messenger", + "threads", "tiktok", "whatsapp", "zalo", diff --git a/apps/builder/__tests__/create-message-action.test.ts b/apps/builder/__tests__/create-message-action.test.ts index ed3ed035d3..3be16618d6 100644 --- a/apps/builder/__tests__/create-message-action.test.ts +++ b/apps/builder/__tests__/create-message-action.test.ts @@ -133,6 +133,7 @@ const contactInbox = { id: "ci-1", inboxId: "inbox-1", contactId: "contact-1", + channel: "messenger", } describe("createMessage", () => { @@ -198,4 +199,52 @@ describe("createMessage", () => { }, }) }) + + test("uses attempts=1 for manual Threads comment replies", async () => { + await createMessage({ + conversation: conversation as never, + contactInbox: { ...contactInbox, channel: "threads" } as never, + parsedInput: { + text: "hello", + replyToMessageId: "parent-1", + replyToMessageCreatedAt: new Date("2026-08-12T00:00:00Z"), + }, + user: { id: "user-1" } as never, + }) + + expect(mockChatQueueAdd).toHaveBeenNthCalledWith( + 2, + "sendChannelMessage", + expect.objectContaining({ + data: expect.objectContaining({ + message: expect.objectContaining({ type: "comment" }), + }), + }), + { attempts: 1 }, + ) + }) + + test("keeps default queue options for non-Threads manual comment replies", async () => { + await createMessage({ + conversation: conversation as never, + contactInbox: { ...contactInbox, channel: "messenger" } as never, + parsedInput: { + text: "hello", + replyToMessageId: "parent-1", + replyToMessageCreatedAt: new Date("2026-08-12T00:00:00Z"), + }, + user: { id: "user-1" } as never, + }) + + expect(mockChatQueueAdd).toHaveBeenNthCalledWith( + 2, + "sendChannelMessage", + expect.objectContaining({ + data: expect.objectContaining({ + message: expect.objectContaining({ type: "comment" }), + }), + }), + ) + expect(mockChatQueueAdd.mock.calls[1]).toHaveLength(2) + }) }) diff --git a/apps/builder/__tests__/i18n-source-keys.test.ts b/apps/builder/__tests__/i18n-source-keys.test.ts new file mode 100644 index 0000000000..cc392c6cf1 --- /dev/null +++ b/apps/builder/__tests__/i18n-source-keys.test.ts @@ -0,0 +1,253 @@ +// @vitest-environment node +import { readdirSync, readFileSync } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { expect, test } from "vitest" +import { messagesByLocale } from "@/i18n/messages" + +/** + * Catches keys that are *used in source but absent from `en.json`* — the exact + * blind spot the existing `i18n:check` lint step cannot see. + * + * `i18n:check` and `i18n-messages.test.ts` both compare the translated + * catalogs *against* English. A key that is missing from every catalog + * including English is perfectly consistent, so it sails through both, and the + * failure only surfaces at runtime as next-intl's `MISSING_MESSAGE` when the + * component happens to render. + * + * Known limitation: only static string literals are verified. Keys built from + * template literals or variables (`t(`fields.${name}`)`, `t(labelKeys.title)`) + * cannot be resolved statically and are skipped — the test reports how many it + * skipped so the gap stays visible rather than silent. + */ + +const SRC_DIR = fileURLToPath(new URL("../src", import.meta.url)) + +const TRANSLATOR_IDENTIFIER = /^t([A-Z0-9_$]\w*)?$/ +const DECLARATION = + /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(([^)]*)\)/g +const ARRAY_DECLARATION = + /(?:const|let|var)\s*\[([^\]]*)\]\s*=\s*await\s+Promise\.all\(/g +const ANY_TRANSLATOR_CALL = /(?:useTranslations|getTranslations)\s*\(([^)]*)\)/g +const STRING_LITERAL_ARG = /^(["'])((?:\\.|(?!\1).)*)\1$/ +const NAMESPACE_PROPERTY = /namespace:\s*(["'])((?:\\.|(?!\1).)*)\1/ + +/** + * Missing in `en.json` today, and pre-existing — unrelated to whatever change + * added this test. Listed so the suite stays green on the existing state while + * still failing on any *new* missing key. The staleness assertion below deletes + * the excuse the moment the key is added, so this cannot rot into a permanent + * mute. + */ +const KNOWN_MISSING_KEYS = new Set([ + // apps/builder/src/features/ads-campaign/components/messaging-ads-box.tsx — + // spinner aria-label; `messages` exists in en.json but has no `loading` leaf. + "messages.loading", +]) + +const flattenKeys = ( + value: unknown, + prefix = "", + result = new Set(), +): Set => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + result.add(prefix) + return result + } + for (const [key, child] of Object.entries(value)) { + flattenKeys(child, prefix ? `${prefix}.${key}` : key, result) + } + return result +} + +const englishKeys = flattenKeys(messagesByLocale.en) + +const listSourceFiles = (): string[] => + readdirSync(SRC_DIR, { encoding: "utf8", recursive: true }) + .filter((file) => file.endsWith(".ts") || file.endsWith(".tsx")) + .filter( + (file) => + !( + file.includes(`__tests__${path.sep}`) || + file.includes(".test.") || + file.includes(".stories.") + ), + ) + +/** `""` for the root namespace, `null` when the argument is not statically resolvable. */ +const resolveNamespace = (rawArg: string): string | null => { + const arg = rawArg.trim() + if (arg === "") { + return "" + } + + const literal = arg.match(STRING_LITERAL_ARG) + if (literal) { + return literal[2] + } + + const namespaceProperty = arg.match(NAMESPACE_PROPERTY) + if (namespaceProperty) { + return namespaceProperty[2] + } + + return null +} + +const collectFileNamespaces = (source: string): string[] => { + const namespaces = new Set() + for (const match of source.matchAll(ANY_TRANSLATOR_CALL)) { + const namespace = resolveNamespace(match[1]) + if (namespace !== null) { + namespaces.add(namespace) + } + } + return [...namespaces] +} + +/** Identifier → the namespaces it may have been bound to inside one file. */ +const collectTranslators = (source: string): Map> => { + const translators = new Map>() + + const bind = (identifier: string, namespaces: string[]) => { + if (!(TRANSLATOR_IDENTIFIER.test(identifier) && namespaces.length > 0)) { + return + } + const existing = translators.get(identifier) ?? new Set() + for (const namespace of namespaces) { + existing.add(namespace) + } + translators.set(identifier, existing) + } + + for (const match of source.matchAll(DECLARATION)) { + const namespace = resolveNamespace(match[2]) + bind(match[1], namespace === null ? [] : [namespace]) + } + + // `const [t, data] = await Promise.all([getTranslations(), ...])` + const fileNamespaces = collectFileNamespaces(source) + for (const match of source.matchAll(ARRAY_DECLARATION)) { + for (const part of match[1].split(",")) { + bind(part.trim(), fileNamespaces) + } + } + + return translators +} + +type Usage = { column: number; file: string; key: string; line: number } + +const collectUsages = ( + file: string, + source: string, + translators: Map>, +): { dynamic: number; usages: Usage[] } => { + const identifiers = [...translators.keys()].map((identifier) => + identifier.replace(/\$/g, "\\$"), + ) + const callPattern = new RegExp( + `\\b(${identifiers.join("|")})(?:\\.(?:rich|raw|markup))?\\(\\s*(?:(["'])((?:\\\\.|(?!\\2).)*)\\2|(\`))`, + "g", + ) + + const usages: Usage[] = [] + let dynamic = 0 + + for (const match of source.matchAll(callPattern)) { + if (match[4]) { + dynamic += 1 + continue + } + + const before = source.slice(0, match.index) + const line = before.split("\n").length + for (const namespace of translators.get(match[1]) ?? []) { + usages.push({ + column: match.index - before.lastIndexOf("\n"), + file, + key: namespace ? `${namespace}.${match[3]}` : match[3], + line, + }) + } + } + + return { dynamic, usages } +} + +const scanSources = () => { + const usagesByCallSite = new Map() + let dynamicKeys = 0 + + for (const file of listSourceFiles()) { + const source = readFileSync(path.join(SRC_DIR, file), "utf8") + if (!source.includes("Translations(")) { + continue + } + + const translators = collectTranslators(source) + if (translators.size === 0) { + continue + } + + const { dynamic, usages } = collectUsages(file, source, translators) + dynamicKeys += dynamic + for (const usage of usages) { + const callSite = `${usage.file}:${usage.line}:${usage.column}` + usagesByCallSite.set(callSite, [ + ...(usagesByCallSite.get(callSite) ?? []), + usage, + ]) + } + } + + return { dynamicKeys, usagesByCallSite } +} + +const { dynamicKeys, usagesByCallSite } = scanSources() + +// A call site resolves if ANY namespace the identifier may hold in that file +// produces a known key — the lenient direction, so an ambiguous binding can +// never turn into a false failure. +const unresolved = [...usagesByCallSite.values()].filter( + (usages) => !usages.some((usage) => englishKeys.has(usage.key)), +) + +test("every statically resolvable translation key used in src exists in en.json", () => { + const failures = unresolved + .filter( + (usages) => !usages.some((usage) => KNOWN_MISSING_KEYS.has(usage.key)), + ) + .map(([usage, ...alternatives]) => { + const candidates = [usage, ...alternatives].map(({ key }) => key) + return `apps/builder/src/${usage.file}:${usage.line} → ${candidates.join(" | ")}` + }) + .sort() + + expect( + failures, + `Translation keys used in source but missing from apps/builder/messages/en.json:\n${failures.join("\n")}`, + ).toEqual([]) +}) + +test("the known-missing allowlist has no stale entries", () => { + const stillMissing = new Set( + unresolved.flatMap((usages) => usages.map(({ key }) => key)), + ) + + expect( + [...KNOWN_MISSING_KEYS].filter((key) => !stillMissing.has(key)), + "These keys now exist in en.json — drop them from KNOWN_MISSING_KEYS", + ).toEqual([]) +}) + +test("the scan actually reached the source tree", () => { + // Guards against a silent no-op: a refactor that renames `useTranslations`, + // moves `src/`, or breaks the declaration regex would otherwise leave this + // suite green with zero keys checked. + expect(usagesByCallSite.size).toBeGreaterThan(1000) + // Template-literal keys are unverifiable by construction. Capping the count + // (roughly 90 today) keeps that escape hatch from quietly swallowing the + // check wholesale — raise the ceiling deliberately, with a reason. + expect(dynamicKeys).toBeLessThan(200) +}) diff --git a/apps/builder/__tests__/integration-webhook-freeze.test.ts b/apps/builder/__tests__/integration-webhook-freeze.test.ts index cb6b6e797f..26e184863b 100644 --- a/apps/builder/__tests__/integration-webhook-freeze.test.ts +++ b/apps/builder/__tests__/integration-webhook-freeze.test.ts @@ -6,8 +6,12 @@ const workspaceFind = vi.fn() const findIntegrationTelegramByBotId = vi.fn() const findIntegrationTiktokByOpenId = vi.fn() const telegramHandleRequest = vi.fn() +const threadsFindDecryptedByClientId = vi.fn() +const threadsHandleRequest = vi.fn() const tiktokHandleRequest = vi.fn() const loggerInfo = vi.fn() +const loggerDebug = vi.fn() +const loggerError = vi.fn() vi.mock("@chatbotx.io/business", async () => { const { resolveWorkspaceFreezeReason } = await import( @@ -16,6 +20,7 @@ vi.mock("@chatbotx.io/business", async () => { return { customDomainService: { findActiveByDomain: vi.fn() }, platformCredentialService: { + findDecryptedThreadsByClientId: threadsFindDecryptedByClientId, findDecryptedPlatform: vi.fn(), findDecryptedForUser: vi.fn(), }, @@ -59,12 +64,13 @@ vi.mock("@/features/integration-tiktok/queries", () => ({ vi.mock("@/integration", () => ({ integrations: { telegram: { name: "telegram", handleRequest: telegramHandleRequest }, + threads: { name: "threads", handleRequest: threadsHandleRequest }, tiktok: { name: "tiktok", handleRequest: tiktokHandleRequest }, }, })) vi.mock("@/lib/log", () => ({ - logger: { debug: vi.fn(), error: vi.fn(), info: loggerInfo }, + logger: { debug: loggerDebug, error: loggerError, info: loggerInfo }, })) vi.mock("@/lib/oauth-broker", () => ({ @@ -92,6 +98,15 @@ beforeEach(() => { getAccessState.mockResolvedValue({ blocked: false }) workspaceFind.mockResolvedValue(liveWorkspace) telegramHandleRequest.mockResolvedValue("ok") + threadsFindDecryptedByClientId.mockResolvedValue({ + config: { + clientId: "app-1", + clientSecret: "secret-1", + verifyToken: "verify-1", + version: "v1.0", + }, + }) + threadsHandleRequest.mockResolvedValue("ok") tiktokHandleRequest.mockResolvedValue("ok") findIntegrationTelegramByBotId.mockResolvedValue({ auth: { secretText: "secret", metadata: { webhookSecretToken: "token" } }, @@ -196,3 +211,106 @@ describe("tiktok webhook freeze", () => { expect(tiktokHandleRequest).not.toHaveBeenCalled() }) }) + +describe("threads webhook routing", () => { + const request = () => + asNextRequest( + "http://localhost/integrations/threads/webhook?appId=app-1&workspaceId=ws-1", + JSON.stringify({ app_id: "app-1", topic: "moderate" }), + ) + + test("selects the app-specific credential instead of the platform-global default", async () => { + await handleWebhook("threads", request()) + + expect(threadsFindDecryptedByClientId).toHaveBeenCalledWith({ + clientId: "app-1", + }) + expect(threadsHandleRequest).toHaveBeenCalledOnce() + expect(loggerInfo).not.toHaveBeenCalledWith( + expect.anything(), + "Webhook request body", + ) + }) + + test("returns 404 when appId is missing", async () => { + const response = await handleWebhook( + "threads", + asNextRequest("http://localhost/integrations/threads/webhook"), + ) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ + message: "Integration is not configured", + }) + expect(threadsFindDecryptedByClientId).not.toHaveBeenCalled() + }) + + test("returns 404 when the appId does not resolve to a configured credential", async () => { + threadsFindDecryptedByClientId.mockResolvedValueOnce(undefined) + + const response = await handleWebhook("threads", request()) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ + message: "Integration is not configured", + }) + expect(loggerDebug).toHaveBeenCalledWith( + expect.objectContaining({ + appId: "app-1", + integrationType: "threads", + }), + "No configured Threads credential for webhook appId", + ) + }) + + test("sanitizes secrets in logs and returns a generic client-facing error", async () => { + threadsHandleRequest.mockRejectedValueOnce( + new Error( + "request failed https://graph.threads.com/v1.0/replies?access_token=super-secret&client_secret=ultra-secret", + ), + ) + + const response = await handleWebhook("threads", request()) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + message: "Failed to process Threads webhook", + }) + const [payload] = loggerError.mock.calls[0] ?? [] + expect(loggerError).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringContaining("[REDACTED]"), + }), + integrationType: "threads", + status: 500, + }), + "Threads handleRequest failed", + ) + expect(JSON.stringify(payload)).not.toContain("super-secret") + expect(JSON.stringify(payload)).not.toContain("ultra-secret") + }) + + test("preserves 400 for invalid verification or signature-style webhook errors", async () => { + threadsHandleRequest.mockRejectedValueOnce( + new Error("Invalid webhook signature"), + ) + + const response = await handleWebhook("threads", request()) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + message: "Invalid Threads webhook request", + }) + expect(loggerError).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Invalid webhook signature", + }), + integrationType: "threads", + status: 400, + }), + "Threads handleRequest failed", + ) + }) +}) diff --git a/apps/builder/__tests__/oauth-reconnect-callback.test.ts b/apps/builder/__tests__/oauth-reconnect-callback.test.ts index 91f7b9e16e..1ed28947c0 100644 --- a/apps/builder/__tests__/oauth-reconnect-callback.test.ts +++ b/apps/builder/__tests__/oauth-reconnect-callback.test.ts @@ -166,10 +166,17 @@ vi.mock("@chatbotx.io/integration-messenger/apis/page", () => ({ subscribePageToAppWebhook: mockSubscribePageToAppWebhook, })) -vi.mock("@chatbotx.io/sdk", () => ({ - AuthType: { oauth2: "oauth2", custom: "custom" }, - SdkException: class SdkException extends Error {}, -})) +// Spread the real module: the Threads integration reaches this test through +// the reconnect route's import graph and needs `Integration` (and any other +// transitive export) to survive the mock. +vi.mock("@chatbotx.io/sdk", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + AuthType: { ...actual.AuthType, oauth2: "oauth2", custom: "custom" }, + SdkException: class SdkException extends Error {}, + } +}) vi.mock("@chatbotx.io/utils", async (importOriginal) => { const actual = await importOriginal() diff --git a/apps/builder/__tests__/threads-comment-form-capability.test.tsx b/apps/builder/__tests__/threads-comment-form-capability.test.tsx new file mode 100644 index 0000000000..383169a1b3 --- /dev/null +++ b/apps/builder/__tests__/threads-comment-form-capability.test.tsx @@ -0,0 +1,93 @@ +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { FormProvider, useForm } from "react-hook-form" +import { afterEach, describe, expect, test, vi } from "vitest" +import { ThreadsCommentForm } from "@/features/threads-comments/components/threads-comment-form" +import type { CreateThreadsCommentRequest } from "@/features/threads-comments/schema/action" + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock("@/features/flows/provider/flow-hook", () => ({ + useFlowSelectOptions: () => [], +})) + +vi.mock("@/features/ai-agents/provider/ai-agent-store-context", () => ({ + useAIAgentStore: () => [], +})) + +Object.assign(globalThis, { + ResizeObserver: class { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + }, +}) + +const baseValues: CreateThreadsCommentRequest = { + name: "", + post: { type: "all", value: [] }, + publicReply: { type: "none", value: null }, + includeKeywords: { type: "all", value: [] }, + excludeKeywords: [], + options: { + replyToNewContactsOnly: false, + replyOncePerUserPerPost: false, + replyToUsersWhoCommentedOnOtherPosts: true, + ignoreCommentReplies: true, + }, + replyAfter: { type: "immediately", value: 0 }, +} + +function Harness() { + const form = useForm({ + defaultValues: baseValues, + }) + + return ( + + undefined} + onSubmit={(event) => event.preventDefault()} + submitLabel="submit" + /> + + ) +} + +describe("ThreadsCommentForm capability gating", () => { + let container: HTMLDivElement + let root: Root + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + test("does not render unsupported private, like, or hide comment controls", () => { + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + act(() => { + root.render() + }) + + expect(container.textContent).toContain( + "threadsCommentAutomation.publicOnlyNote", + ) + expect(container.textContent).not.toContain( + "threadsCommentAutomation.privateReply", + ) + expect(container.textContent).not.toContain( + "threadsCommentAutomation.options.likeUserComment", + ) + expect(container.textContent).not.toContain( + "threadsCommentAutomation.hideComments", + ) + }) +}) diff --git a/apps/builder/__tests__/threads-comment-form-schema.test.ts b/apps/builder/__tests__/threads-comment-form-schema.test.ts new file mode 100644 index 0000000000..3f201de34a --- /dev/null +++ b/apps/builder/__tests__/threads-comment-form-schema.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "vitest" +import { + createThreadsCommentRequest, + createThreadsCommentRequestSchema, + resolveThreadsCommentValidationMessages, + threadsCommentValidationKeys, + updateThreadsCommentRequest, +} from "../src/features/threads-comments/schema/action" + +describe("threads comment automation schema", () => { + test("accepts a valid public text reply payload", () => { + const parsed = createThreadsCommentRequest.parse({ + name: "Auto reply", + post: { type: "postIds", value: ["12345"] }, + publicReply: { type: "text", value: "Thanks!" }, + includeKeywords: { type: "contain", value: ["hello"] }, + excludeKeywords: ["spam"], + options: { + replyToNewContactsOnly: false, + replyOncePerUserPerPost: true, + replyToUsersWhoCommentedOnOtherPosts: true, + ignoreCommentReplies: true, + }, + replyAfter: { type: "minutes", value: 2 }, + }) + + expect(parsed.publicReply).toEqual({ type: "text", value: "Thanks!" }) + }) + + test("rejects missing specific post ids", () => { + expect(() => + createThreadsCommentRequest.parse({ + name: "Auto reply", + post: { type: "postIds", value: [] }, + publicReply: { type: "none", value: null }, + includeKeywords: { type: "all", value: [] }, + excludeKeywords: [], + options: { + replyToNewContactsOnly: false, + replyOncePerUserPerPost: false, + replyToUsersWhoCommentedOnOtherPosts: true, + ignoreCommentReplies: true, + }, + replyAfter: { type: "immediately", value: 0 }, + }), + ).toThrow(threadsCommentValidationKeys.postIdsRequired) + }) + + test("rejects missing public reply value for flow and AI agent", () => { + expect(() => + createThreadsCommentRequest.parse({ + name: "Auto reply", + post: { type: "all", value: [] }, + publicReply: { type: "flow", value: "" }, + includeKeywords: { type: "all", value: [] }, + excludeKeywords: [], + options: { + replyToNewContactsOnly: false, + replyOncePerUserPerPost: false, + replyToUsersWhoCommentedOnOtherPosts: true, + ignoreCommentReplies: true, + }, + replyAfter: { type: "immediately", value: 0 }, + }), + ).toThrow() + + expect(() => + createThreadsCommentRequest.parse({ + name: "Auto reply", + post: { type: "all", value: [] }, + publicReply: { type: "AIAgent", value: "" }, + includeKeywords: { type: "all", value: [] }, + excludeKeywords: [], + options: { + replyToNewContactsOnly: false, + replyOncePerUserPerPost: false, + replyToUsersWhoCommentedOnOtherPosts: true, + ignoreCommentReplies: true, + }, + replyAfter: { type: "immediately", value: 0 }, + }), + ).toThrow() + }) + + test("rejects unsupported delay values and strips crafted immutable fields", () => { + expect(() => + createThreadsCommentRequest.parse({ + name: "Auto reply", + post: { type: "all", value: [] }, + publicReply: { type: "none", value: null }, + includeKeywords: { type: "all", value: [] }, + excludeKeywords: [], + options: { + replyToNewContactsOnly: false, + replyOncePerUserPerPost: false, + replyToUsersWhoCommentedOnOtherPosts: true, + ignoreCommentReplies: true, + }, + replyAfter: { type: "hours", value: 0 }, + }), + ).toThrow(threadsCommentValidationKeys.delayMustBePositive) + + const parsed = updateThreadsCommentRequest.parse({ + isActive: true, + }) + expect(parsed).toEqual({ isActive: true }) + }) + + test("rejects empty updates", () => { + expect(() => updateThreadsCommentRequest.parse({})).toThrow( + threadsCommentValidationKeys.atLeastOneFieldRequired, + ) + }) + + test("creates a client-side schema with translated validation messages", () => { + const schema = createThreadsCommentRequestSchema( + resolveThreadsCommentValidationMessages((key) => `translated:${key}`), + ) + + expect(() => + schema.parse({ + name: "Auto reply", + post: { type: "all", value: ["12345"] }, + publicReply: { type: "none", value: null }, + includeKeywords: { type: "all", value: [] }, + excludeKeywords: [], + options: { + replyToNewContactsOnly: false, + replyOncePerUserPerPost: false, + replyToUsersWhoCommentedOnOtherPosts: true, + ignoreCommentReplies: true, + }, + replyAfter: { type: "immediately", value: 0 }, + }), + ).toThrow( + `translated:${threadsCommentValidationKeys.postIdsMustBeEmptyForAll}`, + ) + }) +}) diff --git a/apps/builder/__tests__/threads-settings-actions.test.ts b/apps/builder/__tests__/threads-settings-actions.test.ts new file mode 100644 index 0000000000..e7a5e433e8 --- /dev/null +++ b/apps/builder/__tests__/threads-settings-actions.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment node +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { removeSpy, resolveSpy, upsertSpy } = vi.hoisted(() => ({ + removeSpy: vi.fn(), + resolveSpy: vi.fn(), + upsertSpy: vi.fn(), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { authActionClient: chain } +}) + +vi.mock("@chatbotx.io/business", () => ({ + platformCredentialService: { remove: removeSpy, upsert: upsertSpy }, +})) + +vi.mock("../src/features/platform-credentials/scope", () => ({ + credentialScopeSchema: {}, + resolveCredentialScopedUserId: resolveSpy, +})) + +const { threadsCredentialUpdateSchema } = await import( + "@chatbotx.io/database/partials" +) +const { deleteThreadsSettingsAction } = await import( + "../src/features/platform-credentials/threads/delete-threads-settings.action" +) +const { updateThreadsSettingAction } = await import( + "../src/features/platform-credentials/threads/update-threads-settings.action" +) + +const call = (action: unknown) => action as (args: any) => Promise + +describe("Threads credential actions", () => { + beforeEach(() => { + vi.clearAllMocks() + resolveSpy.mockReturnValue("user-1") + }) + + test("upserts all threads credential fields", async () => { + await call(updateThreadsSettingAction)({ + ctx: { user: { id: "user-1" } }, + bindArgsParsedInputs: ["user"], + parsedInput: { + clientId: "id", + version: "v1.0", + verifyToken: "verify", + clientSecret: "secret", + }, + }) + + expect(upsertSpy).toHaveBeenCalledWith({ + userId: "user-1", + type: "threads", + config: { + clientId: "id", + version: "v1.0", + verifyToken: "verify", + clientSecret: "secret", + }, + }) + }) + + test.each([ + "clientId", + "version", + "verifyToken", + "clientSecret", + ])("rejects empty %s", (field) => { + expect( + threadsCredentialUpdateSchema.safeParse({ + clientId: "id", + version: "v1.0", + verifyToken: "verify", + clientSecret: "secret", + [field]: "", + }).success, + ).toBe(false) + }) + + test("deletes user and platform scoped threads credentials", async () => { + await call(deleteThreadsSettingsAction)({ + ctx: { user: { id: "u" } }, + bindArgsParsedInputs: ["user"], + }) + + expect(removeSpy).toHaveBeenCalledWith({ + userId: "user-1", + type: "threads", + }) + + resolveSpy.mockReturnValue(undefined) + await call(deleteThreadsSettingsAction)({ + ctx: { user: { id: "a" } }, + bindArgsParsedInputs: ["platform"], + }) + + expect(removeSpy).toHaveBeenCalledWith({ + userId: undefined, + type: "threads", + }) + }) +}) diff --git a/apps/builder/__tests__/threads-webhook-url.test.ts b/apps/builder/__tests__/threads-webhook-url.test.ts new file mode 100644 index 0000000000..fd35fc3454 --- /dev/null +++ b/apps/builder/__tests__/threads-webhook-url.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test, vi } from "vitest" + +vi.mock("@/lib/oauth-broker", () => ({ + buildBrokerCallbackUrl: (path: string) => `https://broker.example.com${path}`, +})) + +const { buildThreadsWebhookUrl } = await import( + "../src/features/platform-credentials/threads/webhook-url" +) + +describe("buildThreadsWebhookUrl", () => { + test("appends the encoded appId to the broker webhook path", () => { + expect(buildThreadsWebhookUrl("thread app/id?=")).toBe( + "https://broker.example.com/integrations/threads/webhook?appId=thread+app%2Fid%3F%3D", + ) + }) + + test("keeps the webhook path plain when the appId is absent", () => { + expect(buildThreadsWebhookUrl()).toBe( + "https://broker.example.com/integrations/threads/webhook", + ) + }) +}) diff --git a/apps/builder/messages/ar.json b/apps/builder/messages/ar.json index 187d810cd2..23185cf51c 100644 --- a/apps/builder/messages/ar.json +++ b/apps/builder/messages/ar.json @@ -720,6 +720,9 @@ "cloneFromMessenger": "نسخ من Messenger", "clonedFromMessenger": "تم النسخ من Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1895,6 +1898,9 @@ "appSecret": { "label": "سر التطبيق" }, + "version": { + "label": "الإصدار" + }, "webhookVerifyToken": { "label": "رمز التحقق من Webhook" }, @@ -4720,6 +4726,75 @@ "instagramFacebookDescription": "إنستغرام متصل عبر صفحة فيسبوك. يدعم الرد العلني، والرد الخاص، وإخفاء التعليقات، والإعجاب بالتعليقات." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "أتمتة الرد على قصص إنستغرام", "description": "أتمتة الردود على ردود قصص إنستغرام لجهات الاتصال الخاصة بك.", diff --git a/apps/builder/messages/da.json b/apps/builder/messages/da.json index bc52da75e5..906e70382b 100644 --- a/apps/builder/messages/da.json +++ b/apps/builder/messages/da.json @@ -646,6 +646,9 @@ "cloneFromMessenger": "Clone fra Messenger", "clonedFromMessenger": "Cloned fra Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1821,6 +1824,9 @@ "appSecret": { "label": "App Secret" }, + "version": { + "label": "Version" + }, "webhookVerifyToken": { "label": "Webhook Verify Token" }, @@ -4258,6 +4264,75 @@ "instagramFacebookDescription": "Instagram connected via a Facebook Side. Supports public svar, private svar, hiding kommentarer, og liking kommentarer." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Automatisering af Instagram Story-svar", "description": "Automatiser svar på Instagram Story-svar for dine kontakter.", diff --git a/apps/builder/messages/de.json b/apps/builder/messages/de.json index 9f4bce74f7..4aa07d6889 100644 --- a/apps/builder/messages/de.json +++ b/apps/builder/messages/de.json @@ -646,6 +646,9 @@ "cloneFromMessenger": "Von Messenger klonen", "clonedFromMessenger": "Von Messenger geklont" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1821,6 +1824,9 @@ "appSecret": { "label": "App-Secret" }, + "version": { + "label": "Version" + }, "webhookVerifyToken": { "label": "Webhook-Verifizierungstoken" }, @@ -4258,6 +4264,75 @@ "instagramFacebookDescription": "Instagram ist über eine Facebook-Seite verbunden. Unterstützt öffentliche und private Antworten, das Ausblenden und das Liken von Kommentaren." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Instagram-Story-Antwort-Automatisierung", "description": "Automatisieren Sie Antworten auf Instagram-Story-Antworten für Ihre Kontakte.", diff --git a/apps/builder/messages/en.json b/apps/builder/messages/en.json index 93a134bfe2..4bf6174a6a 100644 --- a/apps/builder/messages/en.json +++ b/apps/builder/messages/en.json @@ -720,6 +720,9 @@ "cloneFromMessenger": "Clone from Messenger", "clonedFromMessenger": "Cloned from Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1904,6 +1907,9 @@ "appSecret": { "label": "App Secret" }, + "version": { + "label": "Version" + }, "webhookVerifyToken": { "label": "Webhook Verify Token" }, @@ -4412,6 +4418,75 @@ "instagramFacebookDescription": "Instagram connected via a Facebook Page. Supports public reply, private reply, hiding comments, and liking comments." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Instagram Story Reply Automation", "description": "Automate replies to Instagram story replies for your contacts.", diff --git a/apps/builder/messages/es.json b/apps/builder/messages/es.json index 038adb6d2f..2bfbe44cb6 100644 --- a/apps/builder/messages/es.json +++ b/apps/builder/messages/es.json @@ -720,6 +720,9 @@ "cloneFromMessenger": "Clone desde Messenger", "clonedFromMessenger": "Cloned desde Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1895,6 +1898,9 @@ "appSecret": { "label": "Secreto de aplicación" }, + "version": { + "label": "Versión" + }, "webhookVerifyToken": { "label": "Token de verificación del webhook" }, @@ -4258,6 +4264,75 @@ "instagramFacebookDescription": "Insetiquetaram connected via un Facebook Página. Supports public respuesta, private respuesta, hiding comentarios, y liking comentarios." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Automatización de respuestas a historias de Instagram", "description": "Automatiza las respuestas a las respuestas de historias de Instagram para tus contactos.", diff --git a/apps/builder/messages/fi.json b/apps/builder/messages/fi.json index 454c1fc196..c888b6bccc 100644 --- a/apps/builder/messages/fi.json +++ b/apps/builder/messages/fi.json @@ -646,6 +646,9 @@ "cloneFromMessenger": "Kloonaa Messengeristä", "clonedFromMessenger": "Kloonattu Messengeristä" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1821,6 +1824,9 @@ "appSecret": { "label": "Sovellussalaisuus" }, + "version": { + "label": "Versio" + }, "webhookVerifyToken": { "label": "Webhook-vahvistustunnus" }, @@ -4258,6 +4264,75 @@ "instagramFacebookDescription": "Facebook-sivun kautta yhdistetty Instagram. Tukee julkista vastausta, yksityistä vastausta, kommenttien piilottamista ja kommenteista tykkäämistä." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Instagram-tarinavastausautomaatio", "description": "Automatisoi vastaukset Instagram-tarinavastauksiin yhteystiedoillesi.", diff --git a/apps/builder/messages/fr.json b/apps/builder/messages/fr.json index 3f16f96ddb..13235721f3 100644 --- a/apps/builder/messages/fr.json +++ b/apps/builder/messages/fr.json @@ -646,6 +646,9 @@ "cloneFromMessenger": "Cloner depuis Messenger", "clonedFromMessenger": "Cloné depuis Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1821,6 +1824,9 @@ "appSecret": { "label": "Secret de l’application" }, + "version": { + "label": "Version" + }, "webhookVerifyToken": { "label": "Jeton de vérification du webhook" }, @@ -4258,6 +4264,75 @@ "instagramFacebookDescription": "Instagram connecté via une Page Facebook. Prend en charge les réponses publiques et privées, le masquage des commentaires et les mentions J’aime." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Automatisation des réponses aux stories Instagram", "description": "Automatisez les réponses aux réponses de stories Instagram pour vos contacts.", diff --git a/apps/builder/messages/he.json b/apps/builder/messages/he.json index ba6f864237..466b05f56b 100644 --- a/apps/builder/messages/he.json +++ b/apps/builder/messages/he.json @@ -2481,6 +2481,75 @@ "instagramFacebookDescription": "Instagram מחובר באמצעות דף Facebook. תומך בתשובה ציבורית, תשובה פרטית, הסתרת תגובות וסימון לייק לתגובות." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "אוטומציה למענה לסטורי באינסטגרם", "description": "הפוך את המענה לתגובות על הסטורי שלך באינסטגרם לאוטומטי עבור אנשי הקשר שלך.", @@ -3246,6 +3315,9 @@ "cloneFromMessenger": "שכפול מ-Messenger", "clonedFromMessenger": "שוכפל מ-Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4421,6 +4493,9 @@ "appSecret": { "label": "סוד אפליקציה" }, + "version": { + "label": "גרסה" + }, "webhookVerifyToken": { "label": "אסימון אימות Webhook" }, diff --git a/apps/builder/messages/id.json b/apps/builder/messages/id.json index 7ada3571d9..29070b2e6e 100644 --- a/apps/builder/messages/id.json +++ b/apps/builder/messages/id.json @@ -646,6 +646,9 @@ "cloneFromMessenger": "Clone dari Messenger", "clonedFromMessenger": "Cloned dari Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1821,6 +1824,9 @@ "appSecret": { "label": "Rahasia Aplikasi" }, + "version": { + "label": "Versi" + }, "webhookVerifyToken": { "label": "Token Verifikasi Webhook" }, @@ -4258,6 +4264,75 @@ "instagramFacebookDescription": "Instagram terhubung melalui Halaman Facebook. Mendukung balasan publik, balasan pribadi, menyembunyikan komentar, dan menyukai komentar." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Otomatisasi Balasan Story Instagram", "description": "Otomatiskan balasan untuk balasan Story Instagram bagi kontak Anda.", diff --git a/apps/builder/messages/it.json b/apps/builder/messages/it.json index 0a774fefaa..bd76f0e19b 100644 --- a/apps/builder/messages/it.json +++ b/apps/builder/messages/it.json @@ -23,6 +23,9 @@ "cloneFromMessenger": "Clona da Messenger", "clonedFromMessenger": "Clonato da Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1198,6 +1201,9 @@ "appSecret": { "label": "Segreto app" }, + "version": { + "label": "Versione" + }, "webhookVerifyToken": { "label": "Token di verifica del webhook" }, @@ -4258,6 +4264,75 @@ "instagramFacebookDescription": "Instagram collegato tramite una Pagina Facebook. Supporta risposte pubbliche, risposte private, la possibilità di nascondere i commenti e i Mi piace ai commenti." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Automazione risposte alle Storie di Instagram", "description": "Automatizza le risposte alle risposte alle Storie di Instagram per i tuoi contatti.", diff --git a/apps/builder/messages/ja.json b/apps/builder/messages/ja.json index 5bb3c09449..cd3e18e2c6 100644 --- a/apps/builder/messages/ja.json +++ b/apps/builder/messages/ja.json @@ -646,6 +646,9 @@ "cloneFromMessenger": "Messengerから複製", "clonedFromMessenger": "Messengerから複製済み" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1821,6 +1824,9 @@ "appSecret": { "label": "アプリシークレット" }, + "version": { + "label": "バージョン" + }, "webhookVerifyToken": { "label": "Webhook検証トークン" }, @@ -4237,6 +4243,75 @@ "instagramFacebookDescription": "Facebookページ経由で接続されたInstagramです。公開返信、非公開返信、コメントの非表示、コメントへの「いいね!」に対応しています。" } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Instagramストーリー返信オートメーション", "description": "連絡先に対するInstagramストーリーへの返信を自動化します。", diff --git a/apps/builder/messages/nl.json b/apps/builder/messages/nl.json index e1e3498493..034ea1bd45 100644 --- a/apps/builder/messages/nl.json +++ b/apps/builder/messages/nl.json @@ -459,6 +459,9 @@ "cloneFromMessenger": "Klonen vanuit Messenger", "clonedFromMessenger": "Gekloond vanuit Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1634,6 +1637,9 @@ "appSecret": { "label": "Appgeheim" }, + "version": { + "label": "Versie" + }, "webhookVerifyToken": { "label": "Verificatietoken voor webhook" }, @@ -4258,6 +4264,75 @@ "instagramFacebookDescription": "Instagram gekoppeld via een Facebook-pagina. Ondersteunt openbare antwoorden, privéantwoorden, het verbergen van opmerkingen en het leuk vinden van opmerkingen." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Instagram Story-reactie-automatisering", "description": "Automatiseer reacties op Instagram Story-reacties voor je contacten.", diff --git a/apps/builder/messages/pt-BR.json b/apps/builder/messages/pt-BR.json index 13fa3f07d3..603b80871f 100644 --- a/apps/builder/messages/pt-BR.json +++ b/apps/builder/messages/pt-BR.json @@ -646,6 +646,9 @@ "cloneFromMessenger": "Clonar do Messenger", "clonedFromMessenger": "Clonado do Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1821,6 +1824,9 @@ "appSecret": { "label": "Segredo do aplicativo" }, + "version": { + "label": "Versão" + }, "webhookVerifyToken": { "label": "Token de verificação do webhook" }, @@ -4151,6 +4157,75 @@ "instagramFacebookDescription": "Instagram conectado por meio de uma Página do Facebook. Compatível com respostas públicas, respostas privadas, ocultação e curtidas de comentários." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Automação de resposta a Stories do Instagram", "description": "Automatize respostas às respostas de Stories do Instagram para seus contatos.", diff --git a/apps/builder/messages/pt-PT.json b/apps/builder/messages/pt-PT.json index bc3506ee84..54b4afb572 100644 --- a/apps/builder/messages/pt-PT.json +++ b/apps/builder/messages/pt-PT.json @@ -646,6 +646,9 @@ "cloneFromMessenger": "Clonar do Messenger", "clonedFromMessenger": "Clonado do Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1821,6 +1824,9 @@ "appSecret": { "label": "Segredo da aplicação" }, + "version": { + "label": "Versão" + }, "webhookVerifyToken": { "label": "Token de verificação do webhook" }, @@ -4254,6 +4260,75 @@ "instagramFacebookDescription": "Instagram ligado através de uma Página do Facebook. Suporta respostas públicas e privadas, a ocultação de comentários e a colocação de Gosto em comentários." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Automação de resposta a Stories do Instagram", "description": "Automatize respostas às respostas de Stories do Instagram para os seus contactos.", diff --git a/apps/builder/messages/ro.json b/apps/builder/messages/ro.json index 341b8e351f..7c983d28e6 100644 --- a/apps/builder/messages/ro.json +++ b/apps/builder/messages/ro.json @@ -1428,6 +1428,9 @@ "cloneFromMessenger": "Clonează din Messenger", "clonedFromMessenger": "Clonat din Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -2603,6 +2606,9 @@ "appSecret": { "label": "Secretul aplicației" }, + "version": { + "label": "Versiune" + }, "webhookVerifyToken": { "label": "Token de verificare webhook" }, @@ -4414,6 +4420,75 @@ "trackCommentsOn": "Urmărește comentariile la", "trackCommentsOnDescription": "Aplică această automatizare tuturor postărilor de pe contul tău conectat sau restrânge-o la postările specifice pe care le selectezi." }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Automatizare răspunsuri la Story-uri Instagram", "description": "Automatizează răspunsurile la răspunsurile la Story-urile Instagram pentru contactele tale.", diff --git a/apps/builder/messages/sv.json b/apps/builder/messages/sv.json index b5b33267c8..f3c1e3e51a 100644 --- a/apps/builder/messages/sv.json +++ b/apps/builder/messages/sv.json @@ -1390,6 +1390,9 @@ "appSecret": { "label": "Apphemlighet" }, + "version": { + "label": "Version" + }, "assignedId": { "label": "Tilldela till" }, @@ -1875,6 +1878,9 @@ "loginTitle": "Logga in med Instagram", "viaFacebookTitle": "Instagram via Facebook" }, + "threads": { + "label": "Threads" + }, "instagramBusinessFollowsUser": { "label": "Instagram-företaget följer användaren" }, @@ -3250,6 +3256,75 @@ "trackCommentsOn": "Spåra kommentarer på", "trackCommentsOnDescription": "Använd denna automatisering för alla inlägg på ditt anslutna konto eller begränsa den till specifika inlägg som du väljer." }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Automatisering av Instagram Story-svar", "description": "Automatisera svar på Instagram Story-svar för dina kontakter.", diff --git a/apps/builder/messages/tr.json b/apps/builder/messages/tr.json index e3152262d2..9c8336f41e 100644 --- a/apps/builder/messages/tr.json +++ b/apps/builder/messages/tr.json @@ -720,6 +720,9 @@ "cloneFromMessenger": "Messenger'dan Klonla", "clonedFromMessenger": "Messenger'dan Klonlandı" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1895,6 +1898,9 @@ "appSecret": { "label": "Uygulama Gizli Anahtarı" }, + "version": { + "label": "Sürüm" + }, "webhookVerifyToken": { "label": "Webhook Doğrulama Anahtarı" }, @@ -4258,6 +4264,75 @@ "instagramFacebookDescription": "Bir Facebook Sayfası üzerinden bağlanan Instagram. Genel yanıt, özel yanıt, yorumları gizleme ve yorumları beğenmeyi destekler." } }, + "threadsCommentAutomation": { + "title": "Threads Comment Automation", + "description": "Automate public Threads comment replies for your contacts.", + "create": "Create Automation", + "replies": "Replies", + "empty": "No Threads comment automations yet.", + "publicOnlyNote": "Threads currently supports public replies only.", + "trackCommentsOn": "Track comments on", + "publicReply": "Public reply to comment", + "publicReplyDescription": "Send a public reply to matching Threads comments.", + "replyMessage": "Reply message", + "replyFlow": "Reply flow", + "replyAIAgent": "Reply AI Agent", + "includeKeywordsType": "Reply to", + "includeKeywords": "Keywords to match", + "excludeKeywords": "Exclude comments with these keywords", + "keywordsPlaceholder": "Type and press Enter...", + "replyAfter": "Reply after", + "replyAfterValue": "Value", + "specificPostIds": "Specific post IDs", + "postIdPlaceholder": "Enter post IDs and press Enter...", + "card": { + "targeting": "Targeting & Reply", + "filters": "Filters & Options", + "replyTiming": "Reply Timing" + }, + "postType": { + "all": "All posts", + "specificPosts": "Specific posts" + }, + "replyType": { + "text": "Text message", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "No reply" + }, + "keywordsType": { + "all": "All comments", + "equal": "Exact match", + "contain": "Contains" + }, + "replyAfterType": { + "immediately": "Immediately", + "seconds": "After X seconds", + "minutes": "After X minutes", + "hours": "After X hours", + "randomWithin3Minutes": "Random within 3 minutes", + "randomWithin5Minutes": "Random within 5 minutes", + "randomWithin10Minutes": "Random within 10 minutes", + "randomWithin20Minutes": "Random within 20 minutes", + "randomWithin30Minutes": "Random within 30 minutes", + "randomWithin60Minutes": "Random within 60 minutes" + }, + "options": { + "replyToNewContactsOnly": "Only reply to new contacts", + "replyOncePerUserPerPost": "Reply only once to each user per post", + "replyToUsersWhoCommentedOnOtherPosts": "Reply to users who commented on other posts", + "ignoreCommentReplies": "Don't reply to replies to comments" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Post IDs must be empty when targeting all posts.", + "postIdsRequired": "Select at least one post ID when targeting specific posts.", + "keywordsMustBeEmptyForAll": "Keywords must be empty when matching all comments.", + "keywordsRequired": "Enter at least one keyword for the selected match type.", + "delayMustBePositive": "Delay value must be greater than zero.", + "delayMustBeZero": "Delay value must be zero for the selected timing option.", + "atLeastOneFieldRequired": "At least one field is required." + } + }, "instagramStoryAutomation": { "title": "Instagram Hikaye Yanıt Otomasyonu", "description": "Kişileriniz için Instagram hikaye yanıtlarına verilen yanıtları otomatikleştirin.", diff --git a/apps/builder/messages/vi.json b/apps/builder/messages/vi.json index 00c47747a9..0cb79ccc76 100644 --- a/apps/builder/messages/vi.json +++ b/apps/builder/messages/vi.json @@ -720,6 +720,9 @@ "cloneFromMessenger": "Sao chép từ Messenger", "clonedFromMessenger": "Đã sao chép từ Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -1898,6 +1901,9 @@ "appSecret": { "label": "App Secret" }, + "version": { + "label": "Phiên bản" + }, "webhookVerifyToken": { "label": "Webhook Verify Token" }, @@ -4297,6 +4303,75 @@ "instagramFacebookDescription": "Instagram được kết nối qua một trang Facebook. Hỗ trợ trả lời công khai, trả lời riêng tư, ẩn bình luận, và thích bình luận." } }, + "threadsCommentAutomation": { + "title": "Tự động hóa bình luận Threads", + "description": "Tự động hóa phản hồi công khai cho bình luận Threads của các liên hệ của bạn.", + "create": "Tạo tự động hóa", + "replies": "Phản hồi", + "empty": "Chưa có tự động hóa bình luận Threads nào.", + "publicOnlyNote": "Threads hiện chỉ hỗ trợ phản hồi công khai.", + "trackCommentsOn": "Theo dõi bình luận trên", + "publicReply": "Trả lời công khai bình luận", + "publicReplyDescription": "Gửi phản hồi công khai cho các bình luận Threads phù hợp.", + "replyMessage": "Nội dung trả lời", + "replyFlow": "Flow trả lời", + "replyAIAgent": "AI Agent trả lời", + "includeKeywordsType": "Trả lời cho", + "includeKeywords": "Từ khóa cần khớp", + "excludeKeywords": "Loại trừ bình luận có từ khóa", + "keywordsPlaceholder": "Nhập và nhấn Enter...", + "replyAfter": "Trả lời sau", + "replyAfterValue": "Giá trị", + "specificPostIds": "ID bài viết cụ thể", + "postIdPlaceholder": "Nhập ID bài viết rồi nhấn Enter...", + "card": { + "targeting": "Nhắm mục tiêu & Trả lời", + "filters": "Bộ lọc & Tùy chọn", + "replyTiming": "Thời điểm trả lời" + }, + "postType": { + "all": "Tất cả bài viết", + "specificPosts": "Bài viết cụ thể" + }, + "replyType": { + "text": "Tin nhắn văn bản", + "flow": "Flow", + "AIAgent": "AI Agent", + "none": "Không trả lời" + }, + "keywordsType": { + "all": "Tất cả bình luận", + "equal": "Khớp chính xác", + "contain": "Chứa" + }, + "replyAfterType": { + "immediately": "Ngay lập tức", + "seconds": "Sau X giây", + "minutes": "Sau X phút", + "hours": "Sau X giờ", + "randomWithin3Minutes": "Ngẫu nhiên trong 3 phút", + "randomWithin5Minutes": "Ngẫu nhiên trong 5 phút", + "randomWithin10Minutes": "Ngẫu nhiên trong 10 phút", + "randomWithin20Minutes": "Ngẫu nhiên trong 20 phút", + "randomWithin30Minutes": "Ngẫu nhiên trong 30 phút", + "randomWithin60Minutes": "Ngẫu nhiên trong 60 phút" + }, + "options": { + "replyToNewContactsOnly": "Chỉ trả lời liên hệ mới", + "replyOncePerUserPerPost": "Chỉ trả lời một lần mỗi người mỗi bài", + "replyToUsersWhoCommentedOnOtherPosts": "Trả lời người đã bình luận bài khác", + "ignoreCommentReplies": "Không trả lời các reply bình luận" + }, + "validation": { + "postIdsMustBeEmptyForAll": "Danh sách ID bài viết phải để trống khi áp dụng cho tất cả bài viết.", + "postIdsRequired": "Hãy chọn ít nhất một ID bài viết khi áp dụng cho bài viết cụ thể.", + "keywordsMustBeEmptyForAll": "Danh sách từ khóa phải để trống khi áp dụng cho tất cả bình luận.", + "keywordsRequired": "Hãy nhập ít nhất một từ khóa cho kiểu khớp đã chọn.", + "delayMustBePositive": "Giá trị độ trễ phải lớn hơn 0.", + "delayMustBeZero": "Giá trị độ trễ phải bằng 0 với tùy chọn thời gian đã chọn.", + "atLeastOneFieldRequired": "Cần ít nhất một trường để cập nhật." + } + }, "instagramStoryAutomation": { "title": "Tự động hóa trả lời Story Instagram", "description": "Tự động hóa trả lời khi có người reply vào Story Instagram cho các liên hệ của bạn.", diff --git a/apps/builder/messages/zh-CN.json b/apps/builder/messages/zh-CN.json index e856be8785..6e3bece248 100644 --- a/apps/builder/messages/zh-CN.json +++ b/apps/builder/messages/zh-CN.json @@ -1393,6 +1393,9 @@ "appSecret": { "label": "应用程序秘密" }, + "version": { + "label": "版本" + }, "assignedId": { "label": "指派给" }, @@ -1878,6 +1881,9 @@ "loginTitle": "使用 Instagram 登录", "viaFacebookTitle": "Instagram 通过 Facebook" }, + "threads": { + "label": "Threads" + }, "instagramBusinessFollowsUser": { "label": "Instagram 业务跟进用户" }, @@ -3250,6 +3256,75 @@ "trackCommentsOn": "追踪留言的对象", "trackCommentsOnDescription": "可套用到已连接帐号上的所有贴文,或缩小范围到您指定的特定贴文。" }, + "threadsCommentAutomation": { + "title": "Threads 评论自动化", + "description": "自动处理联系人在 Threads 帖子下的公开评论回复。", + "create": "创建自动化", + "replies": "回复", + "empty": "尚未创建 Threads 评论自动化。", + "publicOnlyNote": "Threads 目前仅支持公开回复。", + "trackCommentsOn": "追踪评论的对象", + "publicReply": "公开回复评论", + "publicReplyDescription": "对符合条件的 Threads 评论发表公开回复。", + "replyMessage": "回复消息", + "replyFlow": "回复流程", + "replyAIAgent": "回复 AI Agent", + "includeKeywordsType": "回复对象", + "includeKeywords": "要匹配的关键词", + "excludeKeywords": "排除包含这些关键词的评论", + "keywordsPlaceholder": "输入后按 Enter...", + "replyAfter": "回复时间", + "replyAfterValue": "时间值", + "specificPostIds": "指定帖子 ID", + "postIdPlaceholder": "输入帖子 ID 后按 Enter...", + "card": { + "targeting": "目标与回复", + "filters": "筛选条件与选项", + "replyTiming": "回复时机" + }, + "postType": { + "all": "所有帖子", + "specificPosts": "指定帖子" + }, + "replyType": { + "text": "文本消息", + "flow": "流程", + "AIAgent": "AI Agent", + "none": "不回复" + }, + "keywordsType": { + "all": "所有评论", + "equal": "完全匹配", + "contain": "包含" + }, + "replyAfterType": { + "immediately": "立即", + "seconds": "X 秒后", + "minutes": "X 分钟后", + "hours": "X 小时后", + "randomWithin3Minutes": "3 分钟内随机", + "randomWithin5Minutes": "5 分钟内随机", + "randomWithin10Minutes": "10 分钟内随机", + "randomWithin20Minutes": "20 分钟内随机", + "randomWithin30Minutes": "30 分钟内随机", + "randomWithin60Minutes": "60 分钟内随机" + }, + "options": { + "replyToNewContactsOnly": "仅回复新联系人", + "replyOncePerUserPerPost": "每条帖子对每位用户只回复一次", + "replyToUsersWhoCommentedOnOtherPosts": "回复曾在其他帖子评论的用户", + "ignoreCommentReplies": "忽略评论下的回复" + }, + "validation": { + "postIdsMustBeEmptyForAll": "选择所有帖子时,帖子 ID 必须留空。", + "postIdsRequired": "选择指定帖子时,请至少填入一个帖子 ID。", + "keywordsMustBeEmptyForAll": "匹配所有评论时,关键词必须留空。", + "keywordsRequired": "请为所选的匹配方式至少输入一个关键词。", + "delayMustBePositive": "延迟时间必须大于零。", + "delayMustBeZero": "所选的时机选项不支持延迟时间,请设为零。", + "atLeastOneFieldRequired": "请至少填写一个字段。" + } + }, "instagramStoryAutomation": { "activated": "自动化已激活", "activeStoriesTab": "目前限时动态", diff --git a/apps/builder/messages/zh-TW.json b/apps/builder/messages/zh-TW.json index 0783351848..d766da0b81 100644 --- a/apps/builder/messages/zh-TW.json +++ b/apps/builder/messages/zh-TW.json @@ -50,6 +50,9 @@ "cloneFromMessenger": "從 Messenger 複製", "clonedFromMessenger": "已從 Messenger 複製" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "ZaloOA" }, @@ -1198,6 +1201,9 @@ "appSecret": { "label": "應用程式秘密" }, + "version": { + "label": "版本" + }, "webhookVerifyToken": { "label": "Webhook 驗證權杖" }, @@ -4291,6 +4297,75 @@ "instagramFacebookDescription": "透過 Facebook 粉絲專頁連接的 Instagram。支援公開回覆、私訊回覆、隱藏留言與對留言按讚。" } }, + "threadsCommentAutomation": { + "title": "Threads 留言自動化", + "description": "自動處理聯絡人在 Threads 貼文下的公開留言回覆。", + "create": "建立自動化", + "replies": "回覆", + "empty": "尚未建立 Threads 留言自動化。", + "publicOnlyNote": "Threads 目前僅支援公開回覆。", + "trackCommentsOn": "追蹤留言的對象", + "publicReply": "公開回覆留言", + "publicReplyDescription": "對符合條件的 Threads 留言發表公開回覆。", + "replyMessage": "回覆訊息", + "replyFlow": "回覆流程", + "replyAIAgent": "回覆 AI Agent", + "includeKeywordsType": "回覆對象", + "includeKeywords": "要比對的關鍵字", + "excludeKeywords": "排除包含這些關鍵字的留言", + "keywordsPlaceholder": "輸入後按 Enter...", + "replyAfter": "回覆時間", + "replyAfterValue": "時間值", + "specificPostIds": "指定貼文 ID", + "postIdPlaceholder": "輸入貼文 ID 後按 Enter...", + "card": { + "targeting": "目標與回覆", + "filters": "篩選條件與選項", + "replyTiming": "回覆時機" + }, + "postType": { + "all": "所有貼文", + "specificPosts": "指定貼文" + }, + "replyType": { + "text": "文字訊息", + "flow": "流程", + "AIAgent": "AI Agent", + "none": "不回覆" + }, + "keywordsType": { + "all": "所有留言", + "equal": "完全符合", + "contain": "包含" + }, + "replyAfterType": { + "immediately": "立即", + "seconds": "X 秒後", + "minutes": "X 分鐘後", + "hours": "X 小時後", + "randomWithin3Minutes": "3 分鐘內隨機", + "randomWithin5Minutes": "5 分鐘內隨機", + "randomWithin10Minutes": "10 分鐘內隨機", + "randomWithin20Minutes": "20 分鐘內隨機", + "randomWithin30Minutes": "30 分鐘內隨機", + "randomWithin60Minutes": "60 分鐘內隨機" + }, + "options": { + "replyToNewContactsOnly": "僅回覆新聯絡人", + "replyOncePerUserPerPost": "每則貼文對每位使用者只回覆一次", + "replyToUsersWhoCommentedOnOtherPosts": "回覆曾在其他貼文留言的使用者", + "ignoreCommentReplies": "忽略留言底下的回覆" + }, + "validation": { + "postIdsMustBeEmptyForAll": "選擇所有貼文時,貼文 ID 必須留空。", + "postIdsRequired": "選擇指定貼文時,請至少填入一個貼文 ID。", + "keywordsMustBeEmptyForAll": "比對所有留言時,關鍵字必須留空。", + "keywordsRequired": "請為所選的比對方式至少輸入一個關鍵字。", + "delayMustBePositive": "延遲時間必須大於零。", + "delayMustBeZero": "所選的時機選項不支援延遲時間,請設為零。", + "atLeastOneFieldRequired": "請至少填寫一個欄位。" + } + }, "instagramStoryAutomation": { "title": "Instagram 限時動態回覆自動化", "description": "自動回覆聯絡人對 Instagram 限時動態的回覆。", diff --git a/apps/builder/package.json b/apps/builder/package.json index 1453182992..7a2158756d 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -43,6 +43,7 @@ "@chatbotx.io/integration-google-sheets": "workspace:*", "@chatbotx.io/integration-instagram": "workspace:*", "@chatbotx.io/integration-instagram-facebook": "workspace:*", + "@chatbotx.io/integration-threads": "workspace:*", "@chatbotx.io/integration-klaviyo": "workspace:*", "@chatbotx.io/integration-mailchimp": "workspace:*", "@chatbotx.io/integration-mailer-lite": "workspace:*", diff --git a/apps/builder/src/app/(no-sidebar)/channels/create/page.tsx b/apps/builder/src/app/(no-sidebar)/channels/create/page.tsx index 97dc898432..d33ba8fa69 100644 --- a/apps/builder/src/app/(no-sidebar)/channels/create/page.tsx +++ b/apps/builder/src/app/(no-sidebar)/channels/create/page.tsx @@ -8,6 +8,7 @@ import { InstagramLoginSelect } from "@/features/integration-instagram/component import { generateInstagramRedirectUri } from "@/features/integration-instagram/libs/oauth" import { generateInstagramFacebookRedirectUri } from "@/features/integration-instagram/libs/oauth-facebook" import { TelegramConnect } from "@/features/integration-telegram/components/telegram-connect" +import { generateThreadsRedirectUri } from "@/features/integration-threads/libs/oauth" import { generateTiktokRedirectUri } from "@/features/integration-tiktok/libs/tiktok" import { SimpleCreateWebchat } from "@/features/integration-webchat/simple-create-webchat" import WhatsappCreate from "@/features/integration-whatsapp/components/whatsapp-create" @@ -70,33 +71,44 @@ export default async function CreateChannelPage(props: CreateChannelPageProps) { return } - const [whatsapp, messenger, instagram, instagramFacebook, zalo, tiktok] = - await Promise.all([ - platformCredentialService.resolveForOwner({ - ownerId: platformOwnerId, - type: "whatsapp", - }), - platformCredentialService.resolveForOwner({ - ownerId: platformOwnerId, - type: "messenger", - }), - platformCredentialService.resolveForOwner({ - ownerId: platformOwnerId, - type: "instagram", - }), - platformCredentialService.resolveForOwner({ - ownerId: platformOwnerId, - type: "instagramFacebook", - }), - platformCredentialService.resolveForOwner({ - ownerId: platformOwnerId, - type: "zalo", - }), - platformCredentialService.resolveForOwner({ - ownerId: platformOwnerId, - type: "tiktok", - }), - ]) + const [ + whatsapp, + messenger, + instagram, + instagramFacebook, + threads, + zalo, + tiktok, + ] = await Promise.all([ + platformCredentialService.resolveForOwner({ + ownerId: platformOwnerId, + type: "whatsapp", + }), + platformCredentialService.resolveForOwner({ + ownerId: platformOwnerId, + type: "messenger", + }), + platformCredentialService.resolveForOwner({ + ownerId: platformOwnerId, + type: "instagram", + }), + platformCredentialService.resolveForOwner({ + ownerId: platformOwnerId, + type: "instagramFacebook", + }), + platformCredentialService.resolveForOwner({ + ownerId: platformOwnerId, + type: "threads", + }), + platformCredentialService.resolveForOwner({ + ownerId: platformOwnerId, + type: "zalo", + }), + platformCredentialService.resolveForOwner({ + ownerId: platformOwnerId, + type: "tiktok", + }), + ]) if (selectedChannel === "whatsapp" && whatsapp && isVisible("whatsapp")) { const oauthCallbackUrl = await buildProviderCallbackUrl( @@ -153,6 +165,14 @@ export default async function CreateChannelPage(props: CreateChannelPageProps) { redirect(redirectUri) } + if (selectedChannel === "threads" && threads && isVisible("threads")) { + const redirectUri = await generateThreadsRedirectUri( + threads.publicConfig, + workspaceId, + ) + redirect(redirectUri) + } + if (selectedChannel === "zalo" && zalo && isVisible("zalo")) { const redirectUri = await generateZaloRedirectUri(zalo, workspaceId) redirect(redirectUri) @@ -173,6 +193,9 @@ export default async function CreateChannelPage(props: CreateChannelPageProps) { if (instagram) { configuredChannels.push("instagram") } + if (threads) { + configuredChannels.push("threads") + } if (zalo) { configuredChannels.push("zalo") } diff --git a/apps/builder/src/app/integrations/[...integration]/callback.ts b/apps/builder/src/app/integrations/[...integration]/callback.ts index b778470e14..2a42870ca7 100644 --- a/apps/builder/src/app/integrations/[...integration]/callback.ts +++ b/apps/builder/src/app/integrations/[...integration]/callback.ts @@ -3,6 +3,7 @@ import { instagramIntegrationService, integrationFacebookAdsService, integrationMetaCatalogService, + integrationThreadsService, integrationWhatsappService, messagingAdsConnectionService, messengerIntegrationService, @@ -38,6 +39,11 @@ import { } from "@chatbotx.io/integration-messenger" import { exchangeLongLivedToken as exchangeMessengerLongLivedToken } from "@chatbotx.io/integration-messenger/apis/page" import type { MetaCatalogAuthValue } from "@chatbotx.io/integration-meta-catalog/schemas" +import { + buildThreadsAuthValue, + exchangeCodeForToken as exchangeThreadsCode, + getThreadsProfile, +} from "@chatbotx.io/integration-threads" import { AuthType, type AuthValue, @@ -75,6 +81,7 @@ import { FB_PENDING_AUTH_MAX_AGE, } from "@/lib/facebook-pending-auth" import { logger } from "@/lib/log" +import { buildBrokerCallbackUrl } from "@/lib/oauth-broker" import { resolveRelayTarget, sanitizeReferer } from "@/lib/oauth-referer" import { resolveOwnerForWorkspace } from "@/lib/platform-credential-owner" import { buildProviderCallbackUrl } from "@/lib/provider-origin" @@ -678,6 +685,65 @@ export const handleCallback = async ( ) } + case "threads": { + const threadsCredential = await platformCredentialService.resolveForOwner( + { + ownerId: workspace.ownerId, + type: "threads", + }, + ) + if (!threadsCredential) { + return notFound() + } + + const callbackUrl = buildBrokerCallbackUrl( + "/integrations/threads/callback", + ) + const token = await exchangeThreadsCode( + threadsCredential.config, + code, + callbackUrl, + ) + const profile = await getThreadsProfile( + token.accessToken, + threadsCredential.config.version, + ) + const auth = buildThreadsAuthValue({ + clientId: threadsCredential.config.clientId, + clientSecret: threadsCredential.config.clientSecret, + redirectUrl: callbackUrl, + version: threadsCredential.config.version, + accessToken: token.accessToken, + expiresAt: token.expiresAt, + threadsUserId: profile.id, + username: profile.username, + }) + + if (stateParams.reconnectIntegrationId) { + await integrationThreadsService.reconnect({ + workspaceId: workspace.id, + id: stateParams.reconnectIntegrationId, + auth, + username: profile.username, + name: profile.username, + }) + return redirect( + buildReconnectRedirectUrl(safeReferer, { status: "success" }), + ) + } + + await integrationThreadsService.connect({ + workspaceId: workspace.id, + ownerId: workspace.ownerId, + auth, + threadsUserId: profile.id, + username: profile.username, + name: profile.username, + }) + + return redirect(safeReferer) + } + case "tiktok": { const tiktokCredential = await platformCredentialService.resolveForOwner({ ownerId: platformOwnerId, diff --git a/apps/builder/src/app/integrations/[...integration]/webhook.ts b/apps/builder/src/app/integrations/[...integration]/webhook.ts index ef438a227f..1a85d80f28 100644 --- a/apps/builder/src/app/integrations/[...integration]/webhook.ts +++ b/apps/builder/src/app/integrations/[...integration]/webhook.ts @@ -9,6 +9,7 @@ import { import { db, eq } from "@chatbotx.io/database/client" import { inboxStatuses } from "@chatbotx.io/database/partials" import { inboxModel } from "@chatbotx.io/database/schema" +import { getSafeErrorDetails } from "@chatbotx.io/integration-threads" import type { TiktokAuthValue, TiktokConfig, @@ -27,6 +28,47 @@ type CredentialType = Parameters< typeof platformCredentialService.resolveForOwner >[0]["type"] +const WEBHOOK_PUBLIC_ERROR_HEADERS = { + "Content-Type": "application/json", +} as const + +const THREADS_BAD_REQUEST_MESSAGES = new Set([ + "Empty webhook payload", + "Invalid webhook signature", + "Invalid webhook verification parameters", + "Missing webhook signature", + "Webhook app_id does not match configured clientId", +]) + +const createThreadsErrorResponse = (error: unknown) => { + const safeError = getSafeErrorDetails(error) + + let status = safeError.httpStatusCode + + if (status === undefined) { + if (safeError.message.startsWith("Unsupported HTTP method:")) { + status = 405 + } else if (THREADS_BAD_REQUEST_MESSAGES.has(safeError.message)) { + status = 400 + } else { + status = 500 + } + } + + if (status < 400 || status > 599) { + status = 500 + } + + const isClientError = status >= 400 && status < 500 + return { + publicMessage: isClientError + ? "Invalid Threads webhook request" + : "Failed to process Threads webhook", + safeError, + status, + } +} + /** * Per-bot/per-account channels (telegram, tiktok) reach their integration * handler before any queue consumer runs, so they need the freeze verdict @@ -57,6 +99,10 @@ export const handleWebhook = async ( integrationType: string, req: NextRequest, ) => { + if (integrationType === "threads") { + return handleThreadsWebhook(req) + } + await logWebhookRequestBody(integrationType, req) // Telegram uses per-bot config (not org-level settings) @@ -183,6 +229,78 @@ export const handleWebhook = async ( } } +const handleThreadsWebhook = async (req: NextRequest) => { + const appId = req.nextUrl.searchParams.get("appId")?.trim() + + if (!appId) { + return new Response( + JSON.stringify({ message: "Integration is not configured" }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ) + } + + const integration = integrations.threads + if (!integration?.handleRequest) { + return new Response( + JSON.stringify({ message: "Method is not implemented" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ) + } + + const credential = + await platformCredentialService.findDecryptedThreadsByClientId({ + clientId: appId, + }) + + if (!credential) { + logger.debug( + { appId, integrationType: "threads" }, + "No configured Threads credential for webhook appId", + ) + return new Response( + JSON.stringify({ message: "Integration is not configured" }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ) + } + + const redirectUrl = new URL( + `/integrations/${integration.name}/callback`, + req.nextUrl, + ).toString() + + try { + const result = await integration.handleRequest({ + config: { + ...credential.config, + redirectUrl, + stateParams: { + workspaceId: req.nextUrl.searchParams.get("workspaceId") ?? "", + referer: req.nextUrl.toString(), + }, + // biome-ignore lint/suspicious/noExplicitAny: safe pass value + } as any, + req, + queue: integrationQueue, + }) + + return new Response(result as BodyInit) + } catch (e: unknown) { + const { publicMessage, safeError, status } = createThreadsErrorResponse(e) + logger.error( + { + error: safeError, + integrationType: "threads", + status, + }, + "Threads handleRequest failed", + ) + return new Response(JSON.stringify({ message: publicMessage }), { + status, + headers: WEBHOOK_PUBLIC_ERROR_HEADERS, + }) + } +} + const handleTelegramWebhook = async (req: NextRequest) => { const botId = req.nextUrl.searchParams.get("botId") if (!botId) { diff --git a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/channels/threads/page.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/channels/threads/page.tsx new file mode 100644 index 0000000000..a8f24c7a62 --- /dev/null +++ b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/channels/threads/page.tsx @@ -0,0 +1,35 @@ +import { platformCredentialService } from "@chatbotx.io/business" +import { getIdFromParams } from "@chatbotx.io/utils" +import { notFound } from "next/navigation" +import { listIntegrationThreads } from "@/features/integration-threads/queries" +import { ThreadsManage } from "@/features/integration-threads/threads-manage" +import { requireVisibleChannel } from "@/lib/workspace/require-visible-channel" + +export default async function SettingChannelThreadsPage(props: { + params: Promise<{ workspaceId: string }> +}) { + const workspaceId = getIdFromParams(await props.params, "workspaceId") + if (!workspaceId) { + return notFound() + } + + const policy = await requireVisibleChannel(workspaceId, "threads") + const credential = await platformCredentialService.resolveForOwner({ + ownerId: policy.ownerId, + type: "threads", + }) + + const promises = Promise.all([ + listIntegrationThreads({ + workspaceId, + }), + ]) + + return ( + + ) +} diff --git a/apps/builder/src/app/space/[workspaceId]/threads-comments/[id]/page.tsx b/apps/builder/src/app/space/[workspaceId]/threads-comments/[id]/page.tsx new file mode 100644 index 0000000000..ca72f76897 --- /dev/null +++ b/apps/builder/src/app/space/[workspaceId]/threads-comments/[id]/page.tsx @@ -0,0 +1,27 @@ +import { notFound } from "next/navigation" +import { EditThreadsCommentForm } from "@/features/threads-comments/components/edit-threads-comment-form" +import { getThreadsComment } from "@/features/threads-comments/queries" +import { withWorkspaceIdAndIdSchema } from "@/features/workspaces/schema/resource" + +export default async function EditThreadsCommentPage(props: { + params: Promise<{ workspaceId: string; id: string }> +}) { + const { data } = withWorkspaceIdAndIdSchema.safeParse(await props.params) + if (!data) { + return notFound() + } + + const record = await getThreadsComment(data.workspaceId, data.id).catch( + () => null, + ) + if (!record) { + return notFound() + } + + return ( + + ) +} diff --git a/apps/builder/src/app/space/[workspaceId]/threads-comments/create/page.tsx b/apps/builder/src/app/space/[workspaceId]/threads-comments/create/page.tsx new file mode 100644 index 0000000000..ac5df7b9cd --- /dev/null +++ b/apps/builder/src/app/space/[workspaceId]/threads-comments/create/page.tsx @@ -0,0 +1,14 @@ +import { notFound } from "next/navigation" +import { CreateThreadsCommentForm } from "@/features/threads-comments/components/create-threads-comment-form" +import { withWorkspaceIdSchema } from "@/features/workspaces/schema/resource" + +export default async function CreateThreadsCommentPage(props: { + params: Promise<{ workspaceId: string }> +}) { + const { data } = withWorkspaceIdSchema.safeParse(await props.params) + if (!data) { + return notFound() + } + + return +} diff --git a/apps/builder/src/app/space/[workspaceId]/threads-comments/layout.tsx b/apps/builder/src/app/space/[workspaceId]/threads-comments/layout.tsx new file mode 100644 index 0000000000..7c54f3e6c0 --- /dev/null +++ b/apps/builder/src/app/space/[workspaceId]/threads-comments/layout.tsx @@ -0,0 +1,25 @@ +import { getIdFromParams } from "@chatbotx.io/utils" +import { notFound } from "next/navigation" +import { AIAgentStoreProvider } from "@/features/ai-agents/provider/ai-agent-store-context" +import { FlowStoreProvider } from "@/features/flows/provider/flow-store-context" + +export default async function ThreadsCommentsLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ workspaceId: string }> +}) { + const workspaceId = getIdFromParams(await params, "workspaceId") + if (!workspaceId) { + return notFound() + } + + return ( + + + {children} + + + ) +} diff --git a/apps/builder/src/app/space/[workspaceId]/threads-comments/page.tsx b/apps/builder/src/app/space/[workspaceId]/threads-comments/page.tsx new file mode 100644 index 0000000000..b4edc3a71c --- /dev/null +++ b/apps/builder/src/app/space/[workspaceId]/threads-comments/page.tsx @@ -0,0 +1,24 @@ +import { getIdFromParams } from "@chatbotx.io/utils" +import { notFound } from "next/navigation" +import type { SearchParams } from "nuqs/server" +import { listThreadsComments } from "@/features/threads-comments/queries" +import { listThreadsCommentsSearchParamsCache } from "@/features/threads-comments/schema/action" +import { ThreadsCommentsTable } from "@/features/threads-comments/threads-comments-table" + +export default async function ThreadsCommentsPage(props: { + params: Promise<{ workspaceId: string }> + searchParams: Promise +}) { + const workspaceId = getIdFromParams(await props.params, "workspaceId") + if (!workspaceId) { + return notFound() + } + const search = await listThreadsCommentsSearchParamsCache.parse( + await props.searchParams, + ) + const promises = Promise.all([ + listThreadsComments({ ...search, workspaceId }), + ]) + + return +} diff --git a/apps/builder/src/features/inboxes/components/inbox-icon.tsx b/apps/builder/src/features/inboxes/components/inbox-icon.tsx index 2f98091957..195792e857 100644 --- a/apps/builder/src/features/inboxes/components/inbox-icon.tsx +++ b/apps/builder/src/features/inboxes/components/inbox-icon.tsx @@ -7,6 +7,8 @@ import { SiMessengerHex, SiTelegram, SiTelegramHex, + SiThreads, + SiThreadsHex, SiTiktok, SiTiktokHex, SiWhatsapp, @@ -21,10 +23,12 @@ import { MailIcon, WebhookIcon, } from "lucide-react" +import { useTranslations } from "next-intl" import type { ComponentType, SVGProps } from "react" import { memo } from "react" type IconSize = "small" | "medium" | "large" | "xlarge" +type InboxLabelKey = `fields.${ChannelType}.label` const ICON_SIZE_CLASSES: Record = { small: "size-4", @@ -44,58 +48,63 @@ type InboxIconConfig = { Icon: ComponentType & { fill?: string }> | LucideIcon fill?: string iconClassName?: string - defaultLabel: string + defaultLabelKey: InboxLabelKey } export const INBOX_ICON_CONFIG: Record = { api: { Icon: WebhookIcon, - defaultLabel: "API", + defaultLabelKey: "fields.api.label", }, messenger: { Icon: SiMessenger, fill: SiMessengerHex, - defaultLabel: "Messenger", + defaultLabelKey: "fields.messenger.label", }, instagram: { Icon: SiInstagram, fill: SiInstagramHex, - defaultLabel: "Instagram", + defaultLabelKey: "fields.instagram.label", + }, + threads: { + Icon: SiThreads, + fill: SiThreadsHex, + defaultLabelKey: "fields.threads.label", }, whatsapp: { Icon: SiWhatsapp, fill: SiWhatsappHex, - defaultLabel: "Whatsapp", + defaultLabelKey: "fields.whatsapp.label", }, zalo: { Icon: SiZalo, fill: SiZaloHex, - defaultLabel: "Zalo OA", + defaultLabelKey: "fields.zalo.label", }, telegram: { Icon: SiTelegram, fill: SiTelegramHex, - defaultLabel: "Telegram", + defaultLabelKey: "fields.telegram.label", }, tiktok: { Icon: SiTiktok, fill: SiTiktokHex, - defaultLabel: "TikTok", + defaultLabelKey: "fields.tiktok.label", iconClassName: "[paint-order:stroke_fill] stroke-2 stroke-white dark:fill-zinc-100 dark:stroke-zinc-900", }, webchat: { Icon: AppWindowIcon, iconClassName: "fill-zinc-100 dark:stroke-zinc-800", - defaultLabel: "Webchat", + defaultLabelKey: "fields.webchat.label", }, smtp: { Icon: MailIcon, - defaultLabel: "Email", + defaultLabelKey: "fields.smtp.label", }, omnichannel: { Icon: GlobeIcon, - defaultLabel: "Omnichannel", + defaultLabelKey: "fields.omnichannel.label", }, } @@ -122,6 +131,7 @@ export const InboxIcon = memo( showLabel = true, size = "medium", }: InboxIconProps) => { + const t = useTranslations() const config = isChannelType(channel) ? INBOX_ICON_CONFIG[channel] : INBOX_ICON_CONFIG.omnichannel @@ -129,7 +139,7 @@ export const InboxIcon = memo( Icon, fill, iconClassName: configIconClassName, - defaultLabel, + defaultLabelKey, } = config return ( @@ -146,7 +156,7 @@ export const InboxIcon = memo( - {label ?? defaultLabel} + {label ?? t(defaultLabelKey)} )} diff --git a/apps/builder/src/features/integration-threads/actions/disconnect.action.ts b/apps/builder/src/features/integration-threads/actions/disconnect.action.ts new file mode 100644 index 0000000000..86a58577d6 --- /dev/null +++ b/apps/builder/src/features/integration-threads/actions/disconnect.action.ts @@ -0,0 +1,20 @@ +"use server" + +import { + type WorkspaceIdAndIdRequestParams, + workspaceIdAndIdRequestParams, +} from "@/features/common/schema" +import { workspaceActionClientAllowExpired } from "@/lib/safe-action" +import { disconnectThreads } from "./disconnect" + +export const disconnectThreadsAction = workspaceActionClientAllowExpired + .bindArgsSchemas(workspaceIdAndIdRequestParams) + .action( + async ({ + bindArgsParsedInputs: [workspaceId, integrationThreadsId], + }: { + bindArgsParsedInputs: WorkspaceIdAndIdRequestParams + }) => { + await disconnectThreads({ workspaceId, integrationThreadsId }) + }, + ) diff --git a/apps/builder/src/features/integration-threads/actions/disconnect.ts b/apps/builder/src/features/integration-threads/actions/disconnect.ts new file mode 100644 index 0000000000..8d25264d45 --- /dev/null +++ b/apps/builder/src/features/integration-threads/actions/disconnect.ts @@ -0,0 +1,14 @@ +import { integrationThreadsService } from "@chatbotx.io/business" + +export const disconnectThreads = async ({ + workspaceId, + integrationThreadsId, +}: { + workspaceId: string + integrationThreadsId: string +}) => { + await integrationThreadsService.disconnect({ + workspaceId, + id: integrationThreadsId, + }) +} diff --git a/apps/builder/src/features/integration-threads/actions/reconnect.action.ts b/apps/builder/src/features/integration-threads/actions/reconnect.action.ts new file mode 100644 index 0000000000..c4c63e44a9 --- /dev/null +++ b/apps/builder/src/features/integration-threads/actions/reconnect.action.ts @@ -0,0 +1,55 @@ +"use server" + +import { + integrationThreadsService, + platformCredentialService, +} from "@chatbotx.io/business" +import { ChatbotXException } from "@chatbotx.io/business/errors" +import { generateAuthUrl } from "@chatbotx.io/integration-threads" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { redirect } from "next/navigation" +import { getTranslations } from "next-intl/server" +import { getOriginUrlFromHeader } from "@/lib/domain" +import { buildBrokerCallbackUrl } from "@/lib/oauth-broker" +import { workspaceActionClient } from "@/lib/safe-action" + +export const reconnectThreadsAction = workspaceActionClient + .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()]) + .action( + async ({ bindArgsParsedInputs: [workspaceId, integrationId], ctx }) => { + const t = await getTranslations() + const integrationThreads = + await integrationThreadsService.findByIdForWorkspace({ + id: integrationId, + workspaceId, + }) + if (!integrationThreads) { + throw new ChatbotXException(t("channels.reconnect.errors.notFound")) + } + + const credential = await platformCredentialService.resolveForOwner({ + ownerId: ctx.workspace.ownerId, + type: "threads", + }) + if (!credential) { + throw new ChatbotXException(t("messages.needToAddSettings")) + } + + const referer = new URL( + `/space/${workspaceId}/settings/channels?channel=threads`, + await getOriginUrlFromHeader(), + ).toString() + + return redirect( + generateAuthUrl({ + clientId: credential.config.clientId, + redirectUrl: buildBrokerCallbackUrl("/integrations/threads/callback"), + stateParams: { + workspaceId, + referer, + reconnectIntegrationId: integrationId, + }, + }), + ) + }, + ) diff --git a/apps/builder/src/features/integration-threads/components/threads-disconnect.tsx b/apps/builder/src/features/integration-threads/components/threads-disconnect.tsx new file mode 100644 index 0000000000..08e20b1073 --- /dev/null +++ b/apps/builder/src/features/integration-threads/components/threads-disconnect.tsx @@ -0,0 +1,39 @@ +"use client" + +import type { IntegrationThreadsModel } from "@chatbotx.io/database/types" +import { Button } from "@chatbotx.io/ui/components/ui/button" +import { useTranslations } from "next-intl" +import { useAction } from "next-safe-action/hooks" +import { toast } from "sonner" +import { useWorkspaceId } from "@/hooks/routing" +import { disconnectThreadsAction } from "../actions/disconnect.action" + +export function ThreadsDisconnect({ + integrationThreads, +}: { + integrationThreads: IntegrationThreadsModel +}) { + const t = useTranslations() + const workspaceId = useWorkspaceId() + const { execute, isPending } = useAction( + disconnectThreadsAction.bind(null, workspaceId, integrationThreads.id), + { + onError: ({ error }) => { + if (error.serverError) { + toast.error(error.serverError) + } + }, + }, + ) + + return ( + + ) +} diff --git a/apps/builder/src/features/integration-threads/components/threads-reconnect.tsx b/apps/builder/src/features/integration-threads/components/threads-reconnect.tsx new file mode 100644 index 0000000000..054dc7aa4f --- /dev/null +++ b/apps/builder/src/features/integration-threads/components/threads-reconnect.tsx @@ -0,0 +1,41 @@ +"use client" + +import type { IntegrationThreadsModel } from "@chatbotx.io/database/types" +import { Button } from "@chatbotx.io/ui/components/ui/button" +import { Loader2Icon } from "lucide-react" +import { useTranslations } from "next-intl" +import { useAction } from "next-safe-action/hooks" +import { toast } from "sonner" +import { useWorkspaceId } from "@/hooks/routing" +import { reconnectThreadsAction } from "../actions/reconnect.action" + +export function ThreadsReconnect({ + integrationThreads, +}: { + integrationThreads: IntegrationThreadsModel +}) { + const t = useTranslations() + const workspaceId = useWorkspaceId() + const { execute, isPending } = useAction( + reconnectThreadsAction.bind(null, workspaceId, integrationThreads.id), + { + onError: ({ error }) => { + if (error.serverError) { + toast.error(error.serverError) + } + }, + }, + ) + + return ( + + ) +} diff --git a/apps/builder/src/features/integration-threads/libs/oauth.ts b/apps/builder/src/features/integration-threads/libs/oauth.ts new file mode 100644 index 0000000000..9f444da884 --- /dev/null +++ b/apps/builder/src/features/integration-threads/libs/oauth.ts @@ -0,0 +1,27 @@ +import type { ThreadsCredentialPublic } from "@chatbotx.io/database/partials" +import { generateAuthUrl } from "@chatbotx.io/integration-threads" +import { getOriginFromHeader } from "@/lib/domain" +import { buildBrokerCallbackUrl } from "@/lib/oauth-broker" + +export async function generateThreadsRedirectUri( + publicConfig: ThreadsCredentialPublic, + workspaceId?: string | null, +) { + const redirectUrl = buildBrokerCallbackUrl("/integrations/threads/callback") + const baseUrl = await getOriginFromHeader() + const referer = workspaceId + ? new URL( + `/space/${workspaceId}/settings/channels?channel=threads`, + baseUrl, + ).toString() + : baseUrl + + return generateAuthUrl({ + clientId: publicConfig.clientId, + redirectUrl, + stateParams: { + workspaceId, + referer, + }, + }) +} diff --git a/apps/builder/src/features/integration-threads/queries/index.ts b/apps/builder/src/features/integration-threads/queries/index.ts new file mode 100644 index 0000000000..2230dad8f0 --- /dev/null +++ b/apps/builder/src/features/integration-threads/queries/index.ts @@ -0,0 +1,7 @@ +import { integrationThreadsService } from "@chatbotx.io/business" + +export const listIntegrationThreads = async ({ + workspaceId, +}: { + workspaceId: string +}) => integrationThreadsService.listByWorkspaceId({ workspaceId }) diff --git a/apps/builder/src/features/integration-threads/threads-manage.tsx b/apps/builder/src/features/integration-threads/threads-manage.tsx new file mode 100644 index 0000000000..513e1d5ded --- /dev/null +++ b/apps/builder/src/features/integration-threads/threads-manage.tsx @@ -0,0 +1,90 @@ +"use client" + +import type { ThreadsCredentialPublic } from "@chatbotx.io/database/partials" +import { buttonVariants } from "@chatbotx.io/ui/components/ui/button" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@chatbotx.io/ui/components/ui/table" +import { PlusCircleIcon } from "lucide-react" +import Link from "next/link" +import { useTranslations } from "next-intl" +import { use } from "react" +import { useChannelDuplicatedError } from "@/hooks/use-channel-duplicated-error" +import { ThreadsDisconnect } from "./components/threads-disconnect" +import { ThreadsReconnect } from "./components/threads-reconnect" +import type { listIntegrationThreads } from "./queries" + +export function ThreadsManage({ + publicConfig, + workspaceId, + promises, +}: { + publicConfig: ThreadsCredentialPublic | null + workspaceId: string + promises: Promise<[Awaited>]> +}) { + const [{ data: integrations }] = use(promises) + const t = useTranslations() + + useChannelDuplicatedError("threads") + + if (!publicConfig?.clientId) { + return ( +
+

+ {t("messages.needToAddSettings")} +

+
+ ) + } + + return ( +
+
+ + + {t("actions.addFeature", { feature: t("fields.threads.label") })} + +
+ +
+ + + + {t("fields.name.label")} + + + + + {integrations.map((integration) => ( + + {integration.name} + + + + + + ))} + {integrations.length === 0 && ( + + {t("messages.noData")} + + )} + +
+
+
+ ) +} diff --git a/apps/builder/src/features/messages/actions/create-message.action.ts b/apps/builder/src/features/messages/actions/create-message.action.ts index ee7f388f61..51d121e7e1 100644 --- a/apps/builder/src/features/messages/actions/create-message.action.ts +++ b/apps/builder/src/features/messages/actions/create-message.action.ts @@ -293,19 +293,25 @@ export const createMessage = async (props: { }, }, }), - chatQueue.add(ChatJobAction.sendChannelMessage, { - type: ChatJobAction.sendChannelMessage, - data: { - conversation: targetConversation, - contactInbox, - message: { - ...messageWithAttachments, - clientId: parsedInput.clientId, - parentCreatedAt: parsedInput.replyToMessageCreatedAt ?? null, + chatQueue.add( + ChatJobAction.sendChannelMessage, + { + type: ChatJobAction.sendChannelMessage, + data: { + conversation: targetConversation, + contactInbox, + message: { + ...messageWithAttachments, + clientId: parsedInput.clientId, + parentCreatedAt: parsedInput.replyToMessageCreatedAt ?? null, + }, + sendFrom: "inbox", }, - sendFrom: "inbox", }, - }), + ...(contactInbox.channel === "threads" && message.type === "comment" + ? [{ attempts: 1 }] + : []), + ), ...(user && messageInput.text ? [ chatQueue.add(ChatJobAction.checkOutboundAutomatedResponse, { diff --git a/apps/builder/src/features/messages/components/message-input.tsx b/apps/builder/src/features/messages/components/message-input.tsx index 263d13e321..b79cbddeaa 100644 --- a/apps/builder/src/features/messages/components/message-input.tsx +++ b/apps/builder/src/features/messages/components/message-input.tsx @@ -54,6 +54,7 @@ const CHANNEL_WINDOW_SECONDS: Record = { smtp: 0, telegram: 0, instagram: 24 * 60 * 60, + threads: 0, tiktok: 24 * 60 * 60, } diff --git a/apps/builder/src/features/platform-credentials/manage-platform-credentials.tsx b/apps/builder/src/features/platform-credentials/manage-platform-credentials.tsx index 599c4b6012..11ad3d6ef5 100644 --- a/apps/builder/src/features/platform-credentials/manage-platform-credentials.tsx +++ b/apps/builder/src/features/platform-credentials/manage-platform-credentials.tsx @@ -17,6 +17,7 @@ import { MakeSettings } from "./make/make-settings" import { MessengerSettings } from "./messenger/messenger-settings" import { CredentialScopeProvider } from "./provider/credential-scope-context" import type { CredentialScope } from "./scope" +import { ThreadsSettings } from "./threads/threads-settings" import { TiktokSettings } from "./tiktok/tiktok-settings" import { WhatsappSettings } from "./whatsapp/whatsapp-settings" import { ZaloSettings } from "./zalo/zalo-settings" @@ -79,6 +80,7 @@ export async function ManagePlatformCredentials({ messengerResult, instagramResult, instagramFacebookResult, + threadsResult, googleResult, zaloResult, giphyResult, @@ -89,6 +91,7 @@ export async function ManagePlatformCredentials({ resolveCard(scopedUserId, "messenger"), resolveCard(scopedUserId, "instagram"), resolveCard(scopedUserId, "instagramFacebook"), + resolveCard(scopedUserId, "threads"), resolveCard(scopedUserId, "google"), resolveCard(scopedUserId, "zalo"), resolveCard(scopedUserId, "giphy"), @@ -107,6 +110,8 @@ export async function ManagePlatformCredentials({ instagramFacebookResult.status === "fulfilled" ? instagramFacebookResult.value : emptyCard + const threads = + threadsResult.status === "fulfilled" ? threadsResult.value : emptyCard const google = googleResult.status === "fulfilled" ? googleResult.value : emptyCard const zalo = zaloResult.status === "fulfilled" ? zaloResult.value : emptyCard @@ -148,6 +153,10 @@ export async function ManagePlatformCredentials({ isInherited={instagramFacebook.isInherited} publicConfig={instagramFacebook.publicConfig} /> + { + await platformCredentialService.remove({ + userId: resolveCredentialScopedUserId(ctx.user, scope), + type: "threads", + }) + }) diff --git a/apps/builder/src/features/platform-credentials/threads/threads-settings.tsx b/apps/builder/src/features/platform-credentials/threads/threads-settings.tsx new file mode 100644 index 0000000000..5cae421b91 --- /dev/null +++ b/apps/builder/src/features/platform-credentials/threads/threads-settings.tsx @@ -0,0 +1,231 @@ +"use client" + +import { + type ThreadsCredentialPublic, + type ThreadsCredentialUpdate, + threadsCredentialUpdateSchema, +} from "@chatbotx.io/database/partials" +import { InputField } from "@chatbotx.io/ui/components/form/input-field" +import { Button } from "@chatbotx.io/ui/components/ui/button" +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "@chatbotx.io/ui/components/ui/card" +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@chatbotx.io/ui/components/ui/dialog" +import { Form } from "@chatbotx.io/ui/components/ui/form" +import { zodResolver } from "@hookform/resolvers/zod" +import { SiThreads, SiThreadsHex } from "@icons-pack/react-simple-icons" +import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks" +import { CopyIcon } from "lucide-react" +import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" +import { useAction } from "next-safe-action/hooks" +import { useState } from "react" +import { toast } from "sonner" +import { useClipboard } from "@/hooks/use-clipboard" +import { buildBrokerCallbackUrl } from "@/lib/oauth-broker" +import { CredentialFallbackNote } from "../credential-fallback-note" +import { DeleteCredentialDialog } from "../delete-credential-dialog" +import { useCredentialScope } from "../provider/credential-scope-context" +import { deleteThreadsSettingsAction } from "./delete-threads-settings.action" +import { updateThreadsSettingAction } from "./update-threads-settings.action" +import { buildThreadsWebhookUrl } from "./webhook-url" + +export function ThreadsSettings({ + publicConfig, + isInherited = false, +}: { + publicConfig: ThreadsCredentialPublic | null + isInherited?: boolean +}) { + const t = useTranslations() + const { handleCopy } = useClipboard() + const webhookUrl = buildThreadsWebhookUrl(publicConfig?.clientId) + const authCallbackUrl = buildBrokerCallbackUrl( + "/integrations/threads/callback", + ) + + return ( + + + + + {t("fields.threads.label")} + + + + + + + {publicConfig?.clientId ? ( +
+ + + + +
+ ) : ( + + )} +
+
+ ) +} + +function CredentialRow(props: { + label: string + value: string + onCopy: (value: string) => void +}) { + return ( +
+
{props.label}:
+
+ {props.value} + +
+
+ ) +} + +function EditThreadsSettingsDialog({ + publicConfig, +}: { + publicConfig: ThreadsCredentialPublic | null +}) { + const t = useTranslations() + const [open, setOpen] = useState(false) + const router = useRouter() + + return ( + + + {t("actions.edit")} + + } + /> + + + {t("messages.editFeature", { feature: t("fields.threads.label") })} + + { + setOpen(false) + router.refresh() + }} + publicConfig={publicConfig} + /> + + + ) +} + +function EditThreadsSettingsForm({ + publicConfig, + onClose, +}: { + publicConfig: ThreadsCredentialPublic | null + onClose?: () => void +}) { + const t = useTranslations() + const scope = useCredentialScope() + const { form, handleSubmitWithAction } = useHookFormAction( + updateThreadsSettingAction.bind(null, scope), + zodResolver(threadsCredentialUpdateSchema), + { + actionProps: { + onSuccess: () => onClose?.(), + onError: ({ error }) => { + if (error.serverError) { + toast.error(error.serverError) + } + }, + }, + formProps: { + mode: "onChange", + defaultValues: { + clientId: publicConfig?.clientId ?? "", + version: publicConfig?.version ?? "v1.0", + verifyToken: publicConfig?.verifyToken ?? "", + clientSecret: "", + } satisfies ThreadsCredentialUpdate, + }, + }, + ) + + const { execute: executeDelete, isPending: isDeleting } = useAction( + deleteThreadsSettingsAction.bind(null, scope), + { + onSuccess: () => { + toast.success( + t("messages.deletedSuccess", { feature: t("fields.threads.label") }), + ) + onClose?.() + }, + onError: ({ error }) => + error.serverError && toast.error(error.serverError), + }, + ) + + return ( +
+ + + + + +
+ executeDelete()} + /> + +
+ + + ) +} diff --git a/apps/builder/src/features/platform-credentials/threads/update-threads-settings.action.ts b/apps/builder/src/features/platform-credentials/threads/update-threads-settings.action.ts new file mode 100644 index 0000000000..4bcd0935b8 --- /dev/null +++ b/apps/builder/src/features/platform-credentials/threads/update-threads-settings.action.ts @@ -0,0 +1,28 @@ +"use server" + +import { platformCredentialService } from "@chatbotx.io/business" +import { + type ThreadsCredential, + threadsCredentialUpdateSchema, +} from "@chatbotx.io/database/partials" +import { authActionClient } from "@/lib/safe-action" +import { credentialScopeSchema, resolveCredentialScopedUserId } from "../scope" + +export const updateThreadsSettingAction = authActionClient + .bindArgsSchemas([credentialScopeSchema]) + .inputSchema(threadsCredentialUpdateSchema) + .action(async ({ parsedInput, bindArgsParsedInputs: [scope], ctx }) => { + const scopedUserId = resolveCredentialScopedUserId(ctx.user, scope) + const config: ThreadsCredential = { + clientId: parsedInput.clientId, + version: parsedInput.version, + verifyToken: parsedInput.verifyToken, + clientSecret: parsedInput.clientSecret, + } + + await platformCredentialService.upsert({ + userId: scopedUserId, + type: "threads", + config, + }) + }) diff --git a/apps/builder/src/features/platform-credentials/threads/webhook-url.ts b/apps/builder/src/features/platform-credentials/threads/webhook-url.ts new file mode 100644 index 0000000000..159f4ebe44 --- /dev/null +++ b/apps/builder/src/features/platform-credentials/threads/webhook-url.ts @@ -0,0 +1,11 @@ +import { buildBrokerCallbackUrl } from "@/lib/oauth-broker" + +export const buildThreadsWebhookUrl = (clientId?: string | null): string => { + const url = new URL(buildBrokerCallbackUrl("/integrations/threads/webhook")) + + if (clientId !== undefined && clientId !== null) { + url.searchParams.set("appId", clientId) + } + + return url.toString() +} diff --git a/apps/builder/src/features/shared/comment-automation/types.ts b/apps/builder/src/features/shared/comment-automation/types.ts index 3b31762bb2..8dcf204304 100644 --- a/apps/builder/src/features/shared/comment-automation/types.ts +++ b/apps/builder/src/features/shared/comment-automation/types.ts @@ -13,4 +13,5 @@ export type CommentAutomationRow = { export type CommentAutomationTranslationNamespace = | "facebookCommentAutomation" | "instagramCommentAutomation" + | "threadsCommentAutomation" | "instagramStoryAutomation" diff --git a/apps/builder/src/features/threads-comments/actions/create-threads-comment.action.ts b/apps/builder/src/features/threads-comments/actions/create-threads-comment.action.ts new file mode 100644 index 0000000000..46c129b53e --- /dev/null +++ b/apps/builder/src/features/threads-comments/actions/create-threads-comment.action.ts @@ -0,0 +1,31 @@ +"use server" + +import { fbCommentAutomationService } from "@chatbotx.io/business" +import { + type WorkspaceIdRequestParams, + workspaceIdrequestParams, +} from "@/features/common/schema" +import { workspaceActionClient } from "@/lib/safe-action" +import { + type CreateThreadsCommentRequest, + createThreadsCommentRequest, +} from "../schema/action" + +export const createThreadsCommentAction = workspaceActionClient + .bindArgsSchemas(workspaceIdrequestParams) + .inputSchema(createThreadsCommentRequest) + .action( + async ({ + bindArgsParsedInputs: [workspaceId], + parsedInput, + }: { + bindArgsParsedInputs: WorkspaceIdRequestParams + parsedInput: CreateThreadsCommentRequest + }) => { + const record = await fbCommentAutomationService.createThreadsAutomation({ + workspaceId, + data: parsedInput, + }) + return { id: record.id } + }, + ) diff --git a/apps/builder/src/features/threads-comments/actions/delete-threads-comment.action.ts b/apps/builder/src/features/threads-comments/actions/delete-threads-comment.action.ts new file mode 100644 index 0000000000..a10da0fae1 --- /dev/null +++ b/apps/builder/src/features/threads-comments/actions/delete-threads-comment.action.ts @@ -0,0 +1,23 @@ +"use server" + +import { fbCommentAutomationService } from "@chatbotx.io/business" +import { + type WorkspaceIdAndIdRequestParams, + workspaceIdAndIdRequestParams, +} from "@/features/common/schema" +import { workspaceActionClientAllowExpired } from "@/lib/safe-action" + +export const deleteThreadsCommentAction = workspaceActionClientAllowExpired + .bindArgsSchemas(workspaceIdAndIdRequestParams) + .action( + async ({ + bindArgsParsedInputs: [workspaceId, id], + }: { + bindArgsParsedInputs: WorkspaceIdAndIdRequestParams + }) => { + await fbCommentAutomationService.deleteThreadsAutomation({ + workspaceId, + id, + }) + }, + ) diff --git a/apps/builder/src/features/threads-comments/actions/update-threads-comment.action.ts b/apps/builder/src/features/threads-comments/actions/update-threads-comment.action.ts new file mode 100644 index 0000000000..69f89a6c69 --- /dev/null +++ b/apps/builder/src/features/threads-comments/actions/update-threads-comment.action.ts @@ -0,0 +1,31 @@ +"use server" + +import { fbCommentAutomationService } from "@chatbotx.io/business" +import { + type WorkspaceIdAndIdRequestParams, + workspaceIdAndIdRequestParams, +} from "@/features/common/schema" +import { workspaceActionClient } from "@/lib/safe-action" +import { + type UpdateThreadsCommentRequest, + updateThreadsCommentRequest, +} from "../schema/action" + +export const updateThreadsCommentAction = workspaceActionClient + .bindArgsSchemas(workspaceIdAndIdRequestParams) + .inputSchema(updateThreadsCommentRequest) + .action( + async ({ + bindArgsParsedInputs: [workspaceId, id], + parsedInput, + }: { + bindArgsParsedInputs: WorkspaceIdAndIdRequestParams + parsedInput: UpdateThreadsCommentRequest + }) => { + await fbCommentAutomationService.updateThreadsAutomation({ + workspaceId, + id, + data: parsedInput, + }) + }, + ) diff --git a/apps/builder/src/features/threads-comments/components/create-threads-comment-form.tsx b/apps/builder/src/features/threads-comments/components/create-threads-comment-form.tsx new file mode 100644 index 0000000000..1225f83ab4 --- /dev/null +++ b/apps/builder/src/features/threads-comments/components/create-threads-comment-form.tsx @@ -0,0 +1,77 @@ +"use client" + +import { Form } from "@chatbotx.io/ui/components/ui/form" +import { zodResolver } from "@hookform/resolvers/zod" +import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks" +import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" +import type { UseFormReturn } from "react-hook-form" +import { toast } from "sonner" +import { createThreadsCommentAction } from "../actions/create-threads-comment.action" +import { + type CreateThreadsCommentRequest, + createThreadsCommentRequestSchema, + resolveThreadsCommentValidationMessages, +} from "../schema/action" +import { ThreadsCommentForm } from "./threads-comment-form" + +const defaultValues = { + name: "", + post: { type: "all" as const, value: [] }, + publicReply: { type: "none" as const, value: null }, + includeKeywords: { type: "all" as const, value: [] }, + excludeKeywords: [], + options: { + replyToNewContactsOnly: false, + replyOncePerUserPerPost: false, + replyToUsersWhoCommentedOnOtherPosts: true, + ignoreCommentReplies: true, + }, + replyAfter: { type: "immediately" as const, value: 0 }, +} + +export function CreateThreadsCommentForm({ + workspaceId, +}: { + workspaceId: string +}) { + const t = useTranslations() + const validationMessages = resolveThreadsCommentValidationMessages(t) + const router = useRouter() + + const { form, handleSubmitWithAction } = useHookFormAction( + createThreadsCommentAction.bind(null, workspaceId), + zodResolver(createThreadsCommentRequestSchema(validationMessages)), + { + actionProps: { + onSuccess: () => { + toast.success( + t("messages.createdSuccess", { + feature: t("threadsCommentAutomation.title"), + }), + ) + router.push(`/space/${workspaceId}/threads-comments`) + }, + }, + formProps: { + mode: "onChange", + defaultValues, + }, + }, + ) + + const typedForm = + form as unknown as UseFormReturn + + return ( +
+ router.push(`/space/${workspaceId}/threads-comments`)} + onSubmit={handleSubmitWithAction} + submitLabel={t("actions.create")} + /> + + ) +} diff --git a/apps/builder/src/features/threads-comments/components/edit-threads-comment-form.tsx b/apps/builder/src/features/threads-comments/components/edit-threads-comment-form.tsx new file mode 100644 index 0000000000..52b65da392 --- /dev/null +++ b/apps/builder/src/features/threads-comments/components/edit-threads-comment-form.tsx @@ -0,0 +1,83 @@ +"use client" + +import { Form } from "@chatbotx.io/ui/components/ui/form" +import { zodResolver } from "@hookform/resolvers/zod" +import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" +import { useAction } from "next-safe-action/hooks" +import { type Resolver, type UseFormReturn, useForm } from "react-hook-form" +import { toast } from "sonner" +import { updateThreadsCommentAction } from "../actions/update-threads-comment.action" +import { + type CreateThreadsCommentRequest, + createThreadsCommentRequestSchema, + resolveThreadsCommentValidationMessages, +} from "../schema/action" +import type { ThreadsCommentResource } from "../schema/resource" +import { ThreadsCommentForm } from "./threads-comment-form" + +export function EditThreadsCommentForm({ + workspaceId, + initialData, +}: { + workspaceId: string + initialData: ThreadsCommentResource +}) { + const t = useTranslations() + const validationMessages = resolveThreadsCommentValidationMessages(t) + const router = useRouter() + + const form = useForm({ + resolver: zodResolver( + createThreadsCommentRequestSchema(validationMessages), + ) as Resolver, + mode: "onChange", + defaultValues: { + name: initialData.name, + post: initialData.post, + publicReply: + initialData.publicReply.type === "none" + ? { type: "none", value: null } + : initialData.publicReply, + includeKeywords: initialData.includeKeywords, + excludeKeywords: initialData.excludeKeywords, + options: { + replyToNewContactsOnly: initialData.options.replyToNewContactsOnly, + replyOncePerUserPerPost: initialData.options.replyOncePerUserPerPost, + replyToUsersWhoCommentedOnOtherPosts: + initialData.options.replyToUsersWhoCommentedOnOtherPosts, + ignoreCommentReplies: initialData.options.ignoreCommentReplies, + }, + replyAfter: initialData.replyAfter, + }, + }) + + const { execute, isPending } = useAction( + updateThreadsCommentAction.bind(null, workspaceId, initialData.id), + { + onSuccess: () => { + toast.success( + t("messages.updatedSuccess", { + feature: t("threadsCommentAutomation.title"), + }), + ) + router.refresh() + }, + }, + ) + + const typedForm = + form as unknown as UseFormReturn + + return ( +
+ router.push(`/space/${workspaceId}/threads-comments`)} + onSubmit={form.handleSubmit((data) => execute(data))} + submitLabel={t("actions.save")} + /> + + ) +} diff --git a/apps/builder/src/features/threads-comments/components/threads-comment-form.tsx b/apps/builder/src/features/threads-comments/components/threads-comment-form.tsx new file mode 100644 index 0000000000..a5a88efa4a --- /dev/null +++ b/apps/builder/src/features/threads-comments/components/threads-comment-form.tsx @@ -0,0 +1,416 @@ +"use client" + +import { ComboboxField } from "@chatbotx.io/ui/components/form/combobox-field" +import { InputField } from "@chatbotx.io/ui/components/form/input-field" +import { InputNumberField } from "@chatbotx.io/ui/components/form/input-number-field" +import { RadioGroupField } from "@chatbotx.io/ui/components/form/radio-group-field" +import { SelectField } from "@chatbotx.io/ui/components/form/select-field" +import { SwitchField } from "@chatbotx.io/ui/components/form/switch-field" +import { TextareaField } from "@chatbotx.io/ui/components/form/textarea-field" +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@chatbotx.io/ui/components/ui/card" +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@chatbotx.io/ui/components/ui/form" +import { TagsInputField } from "@chatbotx.io/ui/components/ui/muhammada86/tags-input-field" +import { useTranslations } from "next-intl" +import { useEffect, useRef } from "react" +import type { UseFormReturn } from "react-hook-form" +import { useWatch } from "react-hook-form" +import { useAIAgentStore } from "@/features/ai-agents/provider/ai-agent-store-context" +import { useFlowSelectOptions } from "@/features/flows/provider/flow-hook" +import type { CreateThreadsCommentRequest } from "../schema/action" + +type Props = { + form: UseFormReturn + isSubmitting: boolean + submitLabel: string + onSubmit: (e: React.FormEvent) => void + onCancel: () => void +} + +export function ThreadsCommentForm({ + form, + isSubmitting, + submitLabel, + onSubmit, + onCancel, +}: Props) { + const t = useTranslations() + const flowOptions = useFlowSelectOptions() + const aiAgents = useAIAgentStore((state) => state.aiAgents) + const replyType = useWatch({ + control: form.control, + name: "publicReply.type", + }) + const postType = useWatch({ control: form.control, name: "post.type" }) + const includeKeywordsType = useWatch({ + control: form.control, + name: "includeKeywords.type", + }) + const replyAfterType = useWatch({ + control: form.control, + name: "replyAfter.type", + }) + const aiAgentOptions = aiAgents.map((agent) => ({ + label: agent.name, + value: String(agent.id), + })) + const previousReplyType = useRef(replyType) + + const requiresDelayValue = ["seconds", "minutes", "hours"].includes( + replyAfterType, + ) + + useEffect(() => { + if (postType === "all") { + form.setValue("post.value", [], { + shouldDirty: true, + shouldValidate: true, + }) + } + }, [form, postType]) + + useEffect(() => { + if (includeKeywordsType === "all") { + form.setValue("includeKeywords.value", [], { + shouldDirty: true, + shouldValidate: true, + }) + } + }, [form, includeKeywordsType]) + + useEffect(() => { + const previous = previousReplyType.current + if (replyType === "none") { + form.setValue("publicReply.value", null, { + shouldDirty: true, + shouldValidate: true, + }) + } else if (replyType !== previous) { + form.setValue("publicReply.value", "", { + shouldDirty: true, + shouldValidate: true, + }) + } + previousReplyType.current = replyType + }, [form, replyType]) + + useEffect(() => { + if (!requiresDelayValue) { + form.setValue("replyAfter.value", 0, { + shouldDirty: true, + shouldValidate: true, + }) + } + }, [form, requiresDelayValue]) + + return ( +
+ + + +

+ {t("threadsCommentAutomation.publicOnlyNote")} +

+
+
+ + + + {t("threadsCommentAutomation.card.targeting")} + + + + + {postType === "postIds" ? ( + ( + + + {t("threadsCommentAutomation.specificPostIds")} + + + + + + + )} + /> + ) : null} + + + + {replyType === "text" ? ( + + ) : null} + {replyType === "flow" ? ( + + ) : null} + {replyType === "AIAgent" ? ( + + ) : null} + + + + + + {t("threadsCommentAutomation.card.filters")} + + + + + {includeKeywordsType === "all" ? null : ( + ( + + + {t("threadsCommentAutomation.includeKeywords")} + + + + + + + )} + /> + )} + + ( + + + {t("threadsCommentAutomation.excludeKeywords")} + + + + + + + )} + /> + + + + + + + + + + + + {t("threadsCommentAutomation.card.replyTiming")} + + + + + {requiresDelayValue ? ( + + ) : null} + + + +
+ + +
+
+ ) +} diff --git a/apps/builder/src/features/threads-comments/queries/index.ts b/apps/builder/src/features/threads-comments/queries/index.ts new file mode 100644 index 0000000000..f680358218 --- /dev/null +++ b/apps/builder/src/features/threads-comments/queries/index.ts @@ -0,0 +1,104 @@ +import { fbCommentAutomationService } from "@chatbotx.io/business" +import { fbCommentAutomationModel } from "@chatbotx.io/database/schema" +import { + getPaginationWithDefaults, + parseOrderByAsObject, +} from "@chatbotx.io/database/utils" +import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils" +import type { + ListThreadsCommentsRequest, + ListThreadsCommentsResponse, +} from "../schema/action" +import { threadsCommentResource } from "../schema/resource" + +export async function listThreadsComments( + input: ListThreadsCommentsRequest, +): Promise { + await assertCurrentUserCanAccessChatbot(input.workspaceId) + + const pagination = getPaginationWithDefaults(input) + const orderBy = parseOrderByAsObject(fbCommentAutomationModel, input) + const { data, total } = + await fbCommentAutomationService.listThreadsAutomations({ + workspaceId: input.workspaceId, + name: input.name || undefined, + isActive: input.isActive ?? undefined, + limit: pagination.limit, + offset: pagination.offset, + orderBy, + }) + + return { + data: threadsCommentResource.array().parse( + data.map((item) => ({ + ...item, + post: { + type: item.post.type === "postIds" ? "postIds" : "all", + value: item.post.type === "postIds" ? item.post.value : [], + }, + privateReply: { type: "none", value: null }, + publicReply: + item.publicReply.type === "none" + ? { type: "none", value: null } + : { + type: item.publicReply.type, + value: item.publicReply.value ?? "", + }, + includeKeywords: { + type: + item.includeKeywords.type === "equal" || + item.includeKeywords.type === "contain" + ? item.includeKeywords.type + : "all", + value: + item.includeKeywords.type === "equal" || + item.includeKeywords.type === "contain" + ? item.includeKeywords.value + : [], + }, + })), + ), + pageCount: Math.ceil(total / pagination.limit), + } +} + +export async function getThreadsComment(workspaceId: string, id: string) { + await assertCurrentUserCanAccessChatbot(workspaceId) + + const record = await fbCommentAutomationService.getThreadsAutomation({ + workspaceId, + id, + }) + + if (!record) { + throw new Error("Threads Comment Automation not found") + } + + return threadsCommentResource.parse({ + ...record, + post: { + type: record.post.type === "postIds" ? "postIds" : "all", + value: record.post.type === "postIds" ? record.post.value : [], + }, + privateReply: { type: "none", value: null }, + publicReply: + record.publicReply.type === "none" + ? { type: "none", value: null } + : { + type: record.publicReply.type, + value: record.publicReply.value ?? "", + }, + includeKeywords: { + type: + record.includeKeywords.type === "equal" || + record.includeKeywords.type === "contain" + ? record.includeKeywords.type + : "all", + value: + record.includeKeywords.type === "equal" || + record.includeKeywords.type === "contain" + ? record.includeKeywords.value + : [], + }, + }) +} diff --git a/apps/builder/src/features/threads-comments/schema/action.ts b/apps/builder/src/features/threads-comments/schema/action.ts new file mode 100644 index 0000000000..ba094b30b5 --- /dev/null +++ b/apps/builder/src/features/threads-comments/schema/action.ts @@ -0,0 +1,234 @@ +import type { FBCommentAutomationModel } from "@chatbotx.io/database/types" +import { getSortingStateParser } from "@chatbotx.io/ui/lib/parsers" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { + createSearchParamsCache, + parseAsBoolean, + parseAsInteger, + parseAsString, +} from "nuqs/server" +import z from "zod" +import { basePaginationRequest } from "@/lib/pagination" +import { threadsCommentResource } from "./resource" + +const MAX_NAME_LENGTH = 120 +const MAX_REPLY_LENGTH = 2000 +const MAX_KEYWORDS = 25 +const MAX_POST_IDS = 50 +const MAX_KEYWORD_LENGTH = 120 +const MAX_POST_ID_LENGTH = 120 + +const threadsCommentValidationKeyNames = [ + "postIdsMustBeEmptyForAll", + "postIdsRequired", + "keywordsMustBeEmptyForAll", + "keywordsRequired", + "delayMustBePositive", + "delayMustBeZero", + "atLeastOneFieldRequired", +] as const + +type ThreadsCommentValidationKeyName = + (typeof threadsCommentValidationKeyNames)[number] +type ThreadsCommentValidationMessageKey = + `threadsCommentAutomation.validation.${ThreadsCommentValidationKeyName}` + +type ThreadsCommentValidationMessages = Record< + ThreadsCommentValidationKeyName, + string +> + +export const threadsCommentValidationKeys = Object.fromEntries( + threadsCommentValidationKeyNames.map((key) => [ + key, + `threadsCommentAutomation.validation.${key}`, + ]), +) as Record + +const defaultThreadsCommentValidationMessages: ThreadsCommentValidationMessages = + threadsCommentValidationKeyNames.reduce((messages, key) => { + messages[key] = threadsCommentValidationKeys[key] + return messages + }, {} as ThreadsCommentValidationMessages) + +export function resolveThreadsCommentValidationMessages( + resolver: (key: ThreadsCommentValidationMessageKey) => string, +): ThreadsCommentValidationMessages { + return threadsCommentValidationKeyNames.reduce((messages, key) => { + messages[key] = resolver(threadsCommentValidationKeys[key]) + return messages + }, {} as ThreadsCommentValidationMessages) +} + +const trimmedArray = (maxItems: number, maxLength: number) => + z + .array(z.string().trim().min(1).max(maxLength)) + .max(maxItems) + .transform((values) => [...new Set(values)]) + +export function createThreadsCommentRequestSchema( + validationMessages: ThreadsCommentValidationMessages = defaultThreadsCommentValidationMessages, +) { + const threadsReplySchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("none"), value: z.null() }), + z.object({ + type: z.literal("text"), + value: z.string().trim().min(1).max(MAX_REPLY_LENGTH), + }), + z.object({ + type: z.literal("flow"), + value: zodBigintAsString(), + }), + z.object({ + type: z.literal("AIAgent"), + value: zodBigintAsString(), + }), + ]) + + const threadsPostSchema = z + .object({ + type: z.enum(["all", "postIds"]), + value: trimmedArray(MAX_POST_IDS, MAX_POST_ID_LENGTH), + }) + .superRefine((value, ctx) => { + if (value.type === "all" && value.value.length > 0) { + ctx.addIssue({ + code: "custom", + path: ["value"], + message: validationMessages.postIdsMustBeEmptyForAll, + }) + } + if (value.type === "postIds" && value.value.length === 0) { + ctx.addIssue({ + code: "custom", + path: ["value"], + message: validationMessages.postIdsRequired, + }) + } + }) + + const threadsIncludeKeywordsSchema = z + .object({ + type: z.enum(["all", "equal", "contain"]), + value: trimmedArray(MAX_KEYWORDS, MAX_KEYWORD_LENGTH), + }) + .superRefine((value, ctx) => { + if (value.type === "all" && value.value.length > 0) { + ctx.addIssue({ + code: "custom", + path: ["value"], + message: validationMessages.keywordsMustBeEmptyForAll, + }) + } + if (value.type !== "all" && value.value.length === 0) { + ctx.addIssue({ + code: "custom", + path: ["value"], + message: validationMessages.keywordsRequired, + }) + } + }) + + const threadsOptionsSchema = z.object({ + replyToNewContactsOnly: z.boolean(), + replyOncePerUserPerPost: z.boolean(), + replyToUsersWhoCommentedOnOtherPosts: z.boolean(), + ignoreCommentReplies: z.boolean(), + }) + + const threadsReplyAfterSchema = z + .object({ + type: z.enum([ + "immediately", + "seconds", + "minutes", + "hours", + "randomWithin3Minutes", + "randomWithin5Minutes", + "randomWithin10Minutes", + "randomWithin20Minutes", + "randomWithin30Minutes", + "randomWithin60Minutes", + ]), + value: z.coerce + .number() + .int() + .min(0) + .max(24 * 60 * 60), + }) + .superRefine((value, ctx) => { + const requiresValue = ["seconds", "minutes", "hours"].includes(value.type) + if (requiresValue && value.value <= 0) { + ctx.addIssue({ + code: "custom", + path: ["value"], + message: validationMessages.delayMustBePositive, + }) + } + if (!requiresValue && value.value !== 0) { + ctx.addIssue({ + code: "custom", + path: ["value"], + message: validationMessages.delayMustBeZero, + }) + } + }) + + return z.object({ + name: z.string().trim().min(1).max(MAX_NAME_LENGTH), + post: threadsPostSchema, + publicReply: threadsReplySchema, + includeKeywords: threadsIncludeKeywordsSchema, + excludeKeywords: trimmedArray(MAX_KEYWORDS, MAX_KEYWORD_LENGTH), + options: threadsOptionsSchema, + replyAfter: threadsReplyAfterSchema, + }) +} + +export const listThreadsCommentsRequest = basePaginationRequest.and( + z.object({ + workspaceId: zodBigintAsString(), + name: z.string().nullish(), + isActive: z.boolean().nullish(), + }), +) +export type ListThreadsCommentsRequest = z.infer< + typeof listThreadsCommentsRequest +> + +export const listThreadsCommentsSearchParamsCache = createSearchParamsCache({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + name: parseAsString.withDefault(""), + isActive: parseAsBoolean, + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), +}) + +export const listThreadsCommentsResponse = z.object({ + data: z.array(threadsCommentResource), + pageCount: z.number(), +}) +export type ListThreadsCommentsResponse = z.infer< + typeof listThreadsCommentsResponse +> + +export const createThreadsCommentRequest = createThreadsCommentRequestSchema() +export type CreateThreadsCommentRequest = z.infer< + typeof createThreadsCommentRequest +> + +export const updateThreadsCommentRequest = createThreadsCommentRequest + .partial() + .and( + z.object({ + isActive: z.boolean().optional(), + }), + ) + .refine((value) => Object.keys(value).length > 0, { + message: threadsCommentValidationKeys.atLeastOneFieldRequired, + }) +export type UpdateThreadsCommentRequest = z.infer< + typeof updateThreadsCommentRequest +> diff --git a/apps/builder/src/features/threads-comments/schema/resource.ts b/apps/builder/src/features/threads-comments/schema/resource.ts new file mode 100644 index 0000000000..07c025fdfc --- /dev/null +++ b/apps/builder/src/features/threads-comments/schema/resource.ts @@ -0,0 +1,81 @@ +import { + createSelectSchema, + fbCommentAutomationModel, +} from "@chatbotx.io/database/schema" +import z from "zod" + +const threadsReplySchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("none"), + value: z.null(), + }), + z.object({ + type: z.literal("text"), + value: z.string(), + }), + z.object({ + type: z.literal("flow"), + value: z.string(), + }), + z.object({ + type: z.literal("AIAgent"), + value: z.string(), + }), +]) + +export const threadsCommentResource = createSelectSchema( + fbCommentAutomationModel, + { + id: z.string(), + workspaceId: z.string(), + post: z.object({ + type: z.enum(["all", "postIds"]), + value: z.array(z.string()), + }), + privateReply: z.object({ + type: z.literal("none"), + value: z.null(), + }), + publicReply: threadsReplySchema, + includeKeywords: z.object({ + type: z.enum(["all", "equal", "contain"]), + value: z.array(z.string()), + }), + excludeKeywords: z.array(z.string()), + options: z.object({ + replyToNewContactsOnly: z.boolean(), + replyOncePerUserPerPost: z.boolean(), + likeUserComment: z.boolean(), + replyToUsersWhoCommentedOnOtherPosts: z.boolean(), + ignoreCommentReplies: z.boolean(), + trackUserTags: z.boolean(), + }), + hideComments: z.object({ + all: z.boolean(), + hasPhoneNumber: z.boolean(), + hasImage: z.boolean(), + hasVideo: z.boolean(), + hasLink: z.boolean(), + hasKeywords: z.boolean(), + keywords: z.array(z.string()), + showCommentsAfter: z.literal("none"), + }), + replyAfter: z.object({ + type: z.enum([ + "immediately", + "seconds", + "minutes", + "hours", + "randomWithin3Minutes", + "randomWithin5Minutes", + "randomWithin10Minutes", + "randomWithin20Minutes", + "randomWithin30Minutes", + "randomWithin60Minutes", + ]), + value: z.number(), + }), + }, +) + +export type ThreadsCommentResource = z.infer diff --git a/apps/builder/src/features/threads-comments/threads-comments-table.tsx b/apps/builder/src/features/threads-comments/threads-comments-table.tsx new file mode 100644 index 0000000000..d25ab85300 --- /dev/null +++ b/apps/builder/src/features/threads-comments/threads-comments-table.tsx @@ -0,0 +1,216 @@ +"use client" + +import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table" +import { DataTableColumnHeader } from "@chatbotx.io/ui/components/data-table/data-table-column-header" +import { DataTableToolbar } from "@chatbotx.io/ui/components/data-table/data-table-toolbar" +import { Button } from "@chatbotx.io/ui/components/ui/button" +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@chatbotx.io/ui/components/ui/card" +import { Switch } from "@chatbotx.io/ui/components/ui/switch" +import { useDataTable } from "@chatbotx.io/ui/hooks/use-data-table" +import type { DataTableRowAction } from "@chatbotx.io/ui/types/data-table" +import type { ColumnDef } from "@tanstack/react-table" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" +import React, { use, useCallback, useMemo } from "react" +import { toast } from "sonner" +import { DeleteCommentAutomationDialog } from "../shared/comment-automation/delete-comment-automation-dialog" +import { deleteThreadsCommentAction } from "./actions/delete-threads-comment.action" +import { updateThreadsCommentAction } from "./actions/update-threads-comment.action" +import type { listThreadsComments } from "./queries" +import type { ListThreadsCommentsResponse } from "./schema/action" + +type ThreadsCommentsTableProps = { + workspaceId: string + promises: Promise<[Awaited>]> +} + +export function ThreadsCommentsTable({ + workspaceId, + promises, +}: ThreadsCommentsTableProps) { + const [{ data, pageCount }] = use(promises) + const t = useTranslations() + const router = useRouter() + + const [rowAction, setRowAction] = React.useState | null>(null) + + const handleToggleStatus = useCallback( + async (item: ListThreadsCommentsResponse["data"][number]) => { + try { + const result = await updateThreadsCommentAction(workspaceId, item.id, { + isActive: !item.isActive, + }) + + if (result?.serverError) { + toast.error(result.serverError) + return + } + + router.refresh() + } catch { + toast.error(t("messages.unknownError")) + } + }, + [workspaceId, router, t], + ) + + const columns = useMemo< + ColumnDef[] + >( + () => [ + { + id: "name", + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.name} + + ), + meta: { + label: t("fields.name.label"), + placeholder: t("fields.name.placeholder"), + variant: "text", + }, + enableSorting: true, + enableColumnFilter: true, + }, + { + accessorKey: "isActive", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ handleToggleStatus(row.original)} + /> +
+ ), + size: 100, + }, + { + accessorKey: "repliesCount", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.original.repliesCount}
+ ), + size: 120, + }, + { + id: "actions", + header: () => ( +
{t("actions.actions")}
+ ), + cell: ({ row }) => ( +
+ +
+ ), + size: 180, + enableSorting: false, + enableHiding: false, + }, + ], + [handleToggleStatus, t, workspaceId], + ) + + const { table } = useDataTable({ + data, + columns, + pageCount, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => originalRow.id, + clearOnDefault: true, + shallow: false, + }) + + return ( + + + {t("threadsCommentAutomation.title")} + + + + +