From 715305d47139ef7c86cd5f4c0f2064888ca069a6 Mon Sep 17 00:00:00 2001 From: alansyue Date: Thu, 13 Aug 2026 00:51:46 +0800 Subject: [PATCH 1/7] feat(threads): add comment auto-reply automation --- .../channel-reconnect-actions.test.ts | 79 + .../channels-create-platform-owner.test.ts | 1 + .../__tests__/create-message-action.test.ts | 49 + .../integration-webhook-freeze.test.ts | 120 +- .../oauth-reconnect-callback.test.ts | 15 +- .../threads-comment-form-capability.test.tsx | 93 + .../threads-comment-form-schema.test.ts | 139 + .../threads-settings-actions.test.ts | 108 + .../__tests__/threads-webhook-url.test.ts | 23 + apps/builder/messages/ar.json | 72 + apps/builder/messages/da.json | 72 + apps/builder/messages/de.json | 72 + apps/builder/messages/en.json | 72 + apps/builder/messages/es.json | 72 + apps/builder/messages/fi.json | 72 + apps/builder/messages/fr.json | 72 + apps/builder/messages/he.json | 72 + apps/builder/messages/id.json | 72 + apps/builder/messages/it.json | 72 + apps/builder/messages/ja.json | 72 + apps/builder/messages/nl.json | 72 + apps/builder/messages/pt-BR.json | 72 + apps/builder/messages/pt-PT.json | 72 + apps/builder/messages/ro.json | 72 + apps/builder/messages/sv.json | 72 + apps/builder/messages/tr.json | 72 + apps/builder/messages/vi.json | 72 + apps/builder/messages/zh-CN.json | 72 + apps/builder/messages/zh-TW.json | 72 + apps/builder/package.json | 1 + .../app/(no-sidebar)/channels/create/page.tsx | 77 +- .../integrations/[...integration]/callback.ts | 65 + .../integrations/[...integration]/webhook.ts | 118 + .../settings/channels/threads/page.tsx | 35 + .../threads-comments/[id]/page.tsx | 27 + .../threads-comments/create/page.tsx | 14 + .../[workspaceId]/threads-comments/layout.tsx | 25 + .../[workspaceId]/threads-comments/page.tsx | 24 + .../inboxes/components/inbox-icon.tsx | 36 +- .../actions/disconnect.action.ts | 20 + .../integration-threads/actions/disconnect.ts | 14 + .../actions/reconnect.action.ts | 55 + .../components/threads-disconnect.tsx | 39 + .../components/threads-reconnect.tsx | 41 + .../integration-threads/libs/oauth.ts | 27 + .../integration-threads/queries/index.ts | 7 + .../integration-threads/threads-manage.tsx | 90 + .../messages/actions/create-message.action.ts | 28 +- .../messages/components/message-input.tsx | 1 + .../manage-platform-credentials.tsx | 9 + .../threads/delete-threads-settings.action.ts | 14 + .../threads/threads-settings.tsx | 231 + .../threads/update-threads-settings.action.ts | 28 + .../threads/webhook-url.ts | 11 + .../shared/comment-automation/types.ts | 1 + .../actions/create-threads-comment.action.ts | 31 + .../actions/delete-threads-comment.action.ts | 23 + .../actions/update-threads-comment.action.ts | 31 + .../create-threads-comment-form.tsx | 77 + .../components/edit-threads-comment-form.tsx | 83 + .../components/threads-comment-form.tsx | 416 + .../threads-comments/queries/index.ts | 104 + .../threads-comments/schema/action.ts | 234 + .../threads-comments/schema/resource.ts | 81 + .../threads-comments-table.tsx | 223 + .../builder/src/features/tools/tools-list.tsx | 13 +- apps/builder/src/integration.ts | 2 + .../automated-response-default-reply.test.ts | 56 +- .../__tests__/comment-automation.test.ts | 316 +- .../integrations-service-threads.test.ts | 162 + .../worker/__tests__/received-message.test.ts | 101 + .../__tests__/refresh-threads-tokens.test.ts | 157 + apps/worker/package.json | 1 + .../handlers/automated-response/replies.ts | 48 +- .../handlers/comment-automation/ai-reply.ts | 36 +- .../comment-automation/channel-type.ts | 33 +- .../comment-automation/comment-attachment.ts | 6 +- .../comment-automation/hide-comments.ts | 36 + .../handlers/comment-automation/index.ts | 213 +- .../comment-automation/private-reply.ts | 39 +- .../comment-automation/public-reply.ts | 25 +- .../integration/handlers/received-message.ts | 10 +- .../handlers/refresh-channel-tokens.ts | 2 + .../handlers/refresh-threads-tokens.ts | 116 + apps/worker/src/services/integrations.ts | 60 + .../services/orphaned-integration-cleanup.ts | 6 + integrations/threads/__tests__/auth.test.ts | 153 + .../threads/__tests__/comment.test.ts | 326 + .../__tests__/errors-and-integration.test.ts | 419 + integrations/threads/__tests__/smoke.test.ts | 6 + .../threads/__tests__/webhook.test.ts | 310 + integrations/threads/package.json | 28 + integrations/threads/src/apis/auth.ts | 161 + integrations/threads/src/apis/comment.ts | 154 + integrations/threads/src/constants.ts | 11 + integrations/threads/src/exception.ts | 88 + .../threads/src/handlers/comment/index.ts | 5 + .../src/handlers/comment/outgoing-comment.ts | 55 + integrations/threads/src/handlers/webhook.ts | 186 + integrations/threads/src/index.ts | 9 + integrations/threads/src/integration.ts | 73 + integrations/threads/src/lib/error-mapper.ts | 117 + .../threads/src/lib/error-sanitizer.ts | 77 + integrations/threads/src/lib/http-client.ts | 27 + integrations/threads/src/lib/logger.ts | 3 + integrations/threads/src/lib/webhook.ts | 49 + integrations/threads/src/schema.ts | 35 + integrations/threads/tsconfig.json | 7 + integrations/threads/vitest.config.ts | 1 + .../fb-comment-automation.service.test.ts | 221 + .../integration-threads.service.test.ts | 267 + .../platform-credential-service.test.ts | 91 +- .../src/fb-comment-automation/service.ts | 288 +- packages/business/src/inbox/service.ts | 1 + packages/business/src/inbox/utils.ts | 3 + packages/business/src/index.ts | 1 + .../business/src/integration-threads/index.ts | 1 + .../src/integration-threads/service.ts | 262 + .../src/platform-credential/service.ts | 44 +- .../migration.sql | 17 + .../snapshot.json | 37090 ++++++++++++++++ .../migration.sql | 1 + .../snapshot.json | 37090 ++++++++++++++++ packages/database/src/partials/credential.ts | 32 + .../src/partials/fb-comment-automation.ts | 1 + packages/database/src/partials/integration.ts | 1 + packages/database/src/relations/index.ts | 2 + .../src/relations/integration-threads.ts | 18 + packages/database/src/schema/index.ts | 1 + .../src/schema/integration-threads.ts | 41 + packages/database/src/types.ts | 3 + packages/utils/src/channel.ts | 21 +- .../src/queues/integration/index.ts | 2 +- pnpm-lock.yaml | 37 + 134 files changed, 83514 insertions(+), 168 deletions(-) create mode 100644 apps/builder/__tests__/threads-comment-form-capability.test.tsx create mode 100644 apps/builder/__tests__/threads-comment-form-schema.test.ts create mode 100644 apps/builder/__tests__/threads-settings-actions.test.ts create mode 100644 apps/builder/__tests__/threads-webhook-url.test.ts create mode 100644 apps/builder/src/app/space/[workspaceId]/(settings)/settings/channels/threads/page.tsx create mode 100644 apps/builder/src/app/space/[workspaceId]/threads-comments/[id]/page.tsx create mode 100644 apps/builder/src/app/space/[workspaceId]/threads-comments/create/page.tsx create mode 100644 apps/builder/src/app/space/[workspaceId]/threads-comments/layout.tsx create mode 100644 apps/builder/src/app/space/[workspaceId]/threads-comments/page.tsx create mode 100644 apps/builder/src/features/integration-threads/actions/disconnect.action.ts create mode 100644 apps/builder/src/features/integration-threads/actions/disconnect.ts create mode 100644 apps/builder/src/features/integration-threads/actions/reconnect.action.ts create mode 100644 apps/builder/src/features/integration-threads/components/threads-disconnect.tsx create mode 100644 apps/builder/src/features/integration-threads/components/threads-reconnect.tsx create mode 100644 apps/builder/src/features/integration-threads/libs/oauth.ts create mode 100644 apps/builder/src/features/integration-threads/queries/index.ts create mode 100644 apps/builder/src/features/integration-threads/threads-manage.tsx create mode 100644 apps/builder/src/features/platform-credentials/threads/delete-threads-settings.action.ts create mode 100644 apps/builder/src/features/platform-credentials/threads/threads-settings.tsx create mode 100644 apps/builder/src/features/platform-credentials/threads/update-threads-settings.action.ts create mode 100644 apps/builder/src/features/platform-credentials/threads/webhook-url.ts create mode 100644 apps/builder/src/features/threads-comments/actions/create-threads-comment.action.ts create mode 100644 apps/builder/src/features/threads-comments/actions/delete-threads-comment.action.ts create mode 100644 apps/builder/src/features/threads-comments/actions/update-threads-comment.action.ts create mode 100644 apps/builder/src/features/threads-comments/components/create-threads-comment-form.tsx create mode 100644 apps/builder/src/features/threads-comments/components/edit-threads-comment-form.tsx create mode 100644 apps/builder/src/features/threads-comments/components/threads-comment-form.tsx create mode 100644 apps/builder/src/features/threads-comments/queries/index.ts create mode 100644 apps/builder/src/features/threads-comments/schema/action.ts create mode 100644 apps/builder/src/features/threads-comments/schema/resource.ts create mode 100644 apps/builder/src/features/threads-comments/threads-comments-table.tsx create mode 100644 apps/worker/__tests__/integrations-service-threads.test.ts create mode 100644 apps/worker/__tests__/refresh-threads-tokens.test.ts create mode 100644 apps/worker/src/schedule/handlers/refresh-threads-tokens.ts create mode 100644 integrations/threads/__tests__/auth.test.ts create mode 100644 integrations/threads/__tests__/comment.test.ts create mode 100644 integrations/threads/__tests__/errors-and-integration.test.ts create mode 100644 integrations/threads/__tests__/smoke.test.ts create mode 100644 integrations/threads/__tests__/webhook.test.ts create mode 100644 integrations/threads/package.json create mode 100644 integrations/threads/src/apis/auth.ts create mode 100644 integrations/threads/src/apis/comment.ts create mode 100644 integrations/threads/src/constants.ts create mode 100644 integrations/threads/src/exception.ts create mode 100644 integrations/threads/src/handlers/comment/index.ts create mode 100644 integrations/threads/src/handlers/comment/outgoing-comment.ts create mode 100644 integrations/threads/src/handlers/webhook.ts create mode 100644 integrations/threads/src/index.ts create mode 100644 integrations/threads/src/integration.ts create mode 100644 integrations/threads/src/lib/error-mapper.ts create mode 100644 integrations/threads/src/lib/error-sanitizer.ts create mode 100644 integrations/threads/src/lib/http-client.ts create mode 100644 integrations/threads/src/lib/logger.ts create mode 100644 integrations/threads/src/lib/webhook.ts create mode 100644 integrations/threads/src/schema.ts create mode 100644 integrations/threads/tsconfig.json create mode 100644 integrations/threads/vitest.config.ts create mode 100644 packages/business/__tests__/fb-comment-automation.service.test.ts create mode 100644 packages/business/__tests__/integration-threads.service.test.ts create mode 100644 packages/business/src/integration-threads/index.ts create mode 100644 packages/business/src/integration-threads/service.ts create mode 100644 packages/database/drizzle/20260822110018_add_integration_threads/migration.sql create mode 100644 packages/database/drizzle/20260822110018_add_integration_threads/snapshot.json create mode 100644 packages/database/drizzle/20260822110031_add_threads_comment_automation_type/migration.sql create mode 100644 packages/database/drizzle/20260822110031_add_threads_comment_automation_type/snapshot.json create mode 100644 packages/database/src/relations/integration-threads.ts create mode 100644 packages/database/src/schema/integration-threads.ts diff --git a/apps/builder/__tests__/channel-reconnect-actions.test.ts b/apps/builder/__tests__/channel-reconnect-actions.test.ts index f5ec58e195..1bc5ca0f2e 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, }, @@ -90,10 +97,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"), })) @@ -105,11 +120,13 @@ vi.mock("@/lib/oauth-broker", () => ({ 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 = () => @@ -130,6 +147,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() @@ -320,3 +343,59 @@ describe("reconnectZaloAction", () => { expect(mockRedirect).not.toHaveBeenCalled() }) }) + +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 ba12fdeaba..478056d332 100644 --- a/apps/builder/__tests__/create-message-action.test.ts +++ b/apps/builder/__tests__/create-message-action.test.ts @@ -118,6 +118,7 @@ const contactInbox = { id: "ci-1", inboxId: "inbox-1", contactId: "contact-1", + channel: "messenger", } describe("createMessage", () => { @@ -183,4 +184,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__/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 f0626dea56..17fd86fc92 100644 --- a/apps/builder/__tests__/oauth-reconnect-callback.test.ts +++ b/apps/builder/__tests__/oauth-reconnect-callback.test.ts @@ -139,10 +139,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 4b4b35572f..62d06685c9 100644 --- a/apps/builder/messages/ar.json +++ b/apps/builder/messages/ar.json @@ -715,6 +715,9 @@ "cloneFromMessenger": "نسخ من Messenger", "clonedFromMessenger": "تم النسخ من Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4580,6 +4583,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 b66097309c..cbdef6ec7c 100644 --- a/apps/builder/messages/da.json +++ b/apps/builder/messages/da.json @@ -641,6 +641,9 @@ "cloneFromMessenger": "Clone fra Messenger", "clonedFromMessenger": "Cloned fra Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4184,6 +4187,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 8f32cb27d4..6eca331fd3 100644 --- a/apps/builder/messages/de.json +++ b/apps/builder/messages/de.json @@ -641,6 +641,9 @@ "cloneFromMessenger": "Von Messenger klonen", "clonedFromMessenger": "Von Messenger geklont" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4184,6 +4187,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 d8050b256d..5bc320ab1b 100644 --- a/apps/builder/messages/en.json +++ b/apps/builder/messages/en.json @@ -715,6 +715,9 @@ "cloneFromMessenger": "Clone from Messenger", "clonedFromMessenger": "Cloned from Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4325,6 +4328,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 c35cc23db1..df8f782f7a 100644 --- a/apps/builder/messages/es.json +++ b/apps/builder/messages/es.json @@ -715,6 +715,9 @@ "cloneFromMessenger": "Clone desde Messenger", "clonedFromMessenger": "Cloned desde Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4184,6 +4187,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 0cf0da863f..d1afa257b5 100644 --- a/apps/builder/messages/fi.json +++ b/apps/builder/messages/fi.json @@ -641,6 +641,9 @@ "cloneFromMessenger": "Kloonaa Messengeristä", "clonedFromMessenger": "Kloonattu Messengeristä" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4184,6 +4187,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 51829108f7..ecdafaa971 100644 --- a/apps/builder/messages/fr.json +++ b/apps/builder/messages/fr.json @@ -641,6 +641,9 @@ "cloneFromMessenger": "Cloner depuis Messenger", "clonedFromMessenger": "Cloné depuis Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4184,6 +4187,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 1662d238dc..7ae887b912 100644 --- a/apps/builder/messages/he.json +++ b/apps/builder/messages/he.json @@ -2429,6 +2429,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": "הפוך את המענה לתגובות על הסטורי שלך באינסטגרם לאוטומטי עבור אנשי הקשר שלך.", @@ -3128,6 +3197,9 @@ "cloneFromMessenger": "שכפול מ-Messenger", "clonedFromMessenger": "שוכפל מ-Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, diff --git a/apps/builder/messages/id.json b/apps/builder/messages/id.json index 3ff4e4166e..056d8f1938 100644 --- a/apps/builder/messages/id.json +++ b/apps/builder/messages/id.json @@ -641,6 +641,9 @@ "cloneFromMessenger": "Clone dari Messenger", "clonedFromMessenger": "Cloned dari Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4184,6 +4187,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 eabe6415b3..c00043315c 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" }, @@ -4184,6 +4187,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 8aeadc685d..17c2dd9886 100644 --- a/apps/builder/messages/ja.json +++ b/apps/builder/messages/ja.json @@ -641,6 +641,9 @@ "cloneFromMessenger": "Messengerから複製", "clonedFromMessenger": "Messengerから複製済み" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4163,6 +4166,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 1cd385def4..d0f2c48d52 100644 --- a/apps/builder/messages/nl.json +++ b/apps/builder/messages/nl.json @@ -455,6 +455,9 @@ "cloneFromMessenger": "Klonen vanuit Messenger", "clonedFromMessenger": "Gekloond vanuit Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4184,6 +4187,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 a69f7797fc..94722b2bf4 100644 --- a/apps/builder/messages/pt-BR.json +++ b/apps/builder/messages/pt-BR.json @@ -641,6 +641,9 @@ "cloneFromMessenger": "Clonar do Messenger", "clonedFromMessenger": "Clonado do Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4077,6 +4080,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 69f5179274..ee7b758da7 100644 --- a/apps/builder/messages/pt-PT.json +++ b/apps/builder/messages/pt-PT.json @@ -641,6 +641,9 @@ "cloneFromMessenger": "Clonar do Messenger", "clonedFromMessenger": "Clonado do Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4180,6 +4183,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 0ffe1bcf4a..93a552a724 100644 --- a/apps/builder/messages/ro.json +++ b/apps/builder/messages/ro.json @@ -1397,6 +1397,9 @@ "cloneFromMessenger": "Clonează din Messenger", "clonedFromMessenger": "Clonat din Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4340,6 +4343,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 71280e29b0..204afb3839 100644 --- a/apps/builder/messages/sv.json +++ b/apps/builder/messages/sv.json @@ -1829,6 +1829,9 @@ "loginTitle": "Logga in med Instagram", "viaFacebookTitle": "Instagram via Facebook" }, + "threads": { + "label": "Threads" + }, "instagramBusinessFollowsUser": { "label": "Instagram-företaget följer användaren" }, @@ -3181,6 +3184,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 e179aebd7d..408b8231f5 100644 --- a/apps/builder/messages/tr.json +++ b/apps/builder/messages/tr.json @@ -715,6 +715,9 @@ "cloneFromMessenger": "Messenger'dan Klonla", "clonedFromMessenger": "Messenger'dan Klonlandı" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4184,6 +4187,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 012b9b48cc..72d4f9e2c9 100644 --- a/apps/builder/messages/vi.json +++ b/apps/builder/messages/vi.json @@ -715,6 +715,9 @@ "cloneFromMessenger": "Sao chép từ Messenger", "clonedFromMessenger": "Đã sao chép từ Messenger" }, + "threads": { + "label": "Threads" + }, "zalo": { "label": "Zalo OA" }, @@ -4223,6 +4226,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 c966632fe7..30898c81db 100644 --- a/apps/builder/messages/zh-CN.json +++ b/apps/builder/messages/zh-CN.json @@ -1832,6 +1832,9 @@ "loginTitle": "使用 Instagram 登录", "viaFacebookTitle": "Instagram 通过 Facebook" }, + "threads": { + "label": "Threads" + }, "instagramBusinessFollowsUser": { "label": "Instagram 业务跟进用户" }, @@ -3181,6 +3184,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 8c18251dd0..807d321c6a 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" }, @@ -4217,6 +4220,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 9d57cab795..7084c05704 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 412a8d5e89..3001415e5d 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" @@ -68,33 +69,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")) { return ( @@ -146,6 +158,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.publicConfig, @@ -172,6 +192,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 c27e3b9dda..5e2e227cae 100644 --- a/apps/builder/src/app/integrations/[...integration]/callback.ts +++ b/apps/builder/src/app/integrations/[...integration]/callback.ts @@ -2,6 +2,7 @@ import { appointmentExternalCalendarService, integrationFacebookAdsService, integrationMetaCatalogService, + integrationThreadsService, platformCredentialService, workspaceMemberService, workspaceService, @@ -29,6 +30,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, @@ -484,6 +490,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..9551dd59f3 --- /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/schemas" +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 affac0ff37..b174c6ba6e 100644 --- a/apps/builder/src/features/messages/actions/create-message.action.ts +++ b/apps/builder/src/features/messages/actions/create-message.action.ts @@ -181,19 +181,25 @@ export const createMessage = async (props: { }, }, }), - chatQueue.add(ChatJobAction.sendChannelMessage, { - type: ChatJobAction.sendChannelMessage, - data: { - conversation, - contactInbox, - message: { - ...messageWithAttachments, - clientId: parsedInput.clientId, - parentCreatedAt: parsedInput.replyToMessageCreatedAt ?? null, + chatQueue.add( + ChatJobAction.sendChannelMessage, + { + type: ChatJobAction.sendChannelMessage, + data: { + conversation, + 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 0af91bf84c..d4f6babd3e 100644 --- a/apps/builder/src/features/messages/components/message-input.tsx +++ b/apps/builder/src/features/messages/components/message-input.tsx @@ -60,6 +60,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 0adab377e0..58d94ea7ee 100644 --- a/apps/builder/src/features/platform-credentials/manage-platform-credentials.tsx +++ b/apps/builder/src/features/platform-credentials/manage-platform-credentials.tsx @@ -12,6 +12,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" @@ -74,6 +75,7 @@ export async function ManagePlatformCredentials({ messengerResult, instagramResult, instagramFacebookResult, + threadsResult, googleResult, zaloResult, giphyResult, @@ -84,6 +86,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"), @@ -102,6 +105,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 @@ -126,6 +131,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..f6d9983286 --- /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/schemas/index" +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..862ded51d2 --- /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/schemas/index" +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..0753d1a1ea --- /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/schemas/index" +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..aea1d8c865 --- /dev/null +++ b/apps/builder/src/features/threads-comments/threads-comments-table.tsx @@ -0,0 +1,223 @@ +"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 { Badge } from "@chatbotx.io/ui/components/ui/badge" +import { Button } from "@chatbotx.io/ui/components/ui/button" +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@chatbotx.io/ui/components/ui/card" +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 }) => ( +
+ +
+ ), + size: 120, + }, + { + 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")} + + + + + + handleToggleStatus(row.original)} + /> ), - size: 120, + size: 100, }, { accessorKey: "repliesCount", From 137b0406e43ee3ab7af64043b1f37d5ab6b22e56 Mon Sep 17 00:00:00 2001 From: alansyue Date: Tue, 1 Sep 2026 17:28:18 +0800 Subject: [PATCH 6/7] test: guard the registration gaps that shipped green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of the bugs fixed in this branch passed CI, because the surfaces they broke are either resolved at runtime by Drizzle or reachable only through a switch that has a `default` arm. These assert the invariants directly rather than the behaviour of one channel, so a future channel that forgets a registration fails here instead of in production. - every relation `InboxService.withIntegrations` eager-loads is defined on `inboxModel`, and the converse - every statically resolvable `t()` key in the builder source exists in `en.json` — the gap `i18n:check` leaves open, since it only compares locales against each other - `disconnectWorkspaceChannels` deletes each channel's integration row rather than falling through to `default` - the variables channel maps resolve every channel Each was verified by reverting its fix and confirming the test fails, so none of them is vacuous. The i18n test additionally asserts its own scan reached the source tree, so a broken matcher cannot silently turn it into a no-op. `api` is outside the matrix by construction: it has no `integration*` relation on `inboxModel` to derive from. That is a pre-existing gap, noted in both test files and reported separately. --- .../__tests__/i18n-source-keys.test.ts | 253 ++++++++++++++++++ .../inbox-with-integrations-relations.test.ts | 141 ++++++++++ ...ifecycle.channel-switch-exhaustive.test.ts | 170 ++++++++++++ ...ntegration-fields-channel-coverage.test.ts | 177 ++++++++++++ 4 files changed, 741 insertions(+) create mode 100644 apps/builder/__tests__/i18n-source-keys.test.ts create mode 100644 packages/business/__tests__/inbox-with-integrations-relations.test.ts create mode 100644 packages/business/__tests__/workspace-lifecycle.channel-switch-exhaustive.test.ts create mode 100644 packages/variables/__tests__/integration-fields-channel-coverage.test.ts 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/packages/business/__tests__/inbox-with-integrations-relations.test.ts b/packages/business/__tests__/inbox-with-integrations-relations.test.ts new file mode 100644 index 0000000000..e5a5a49251 --- /dev/null +++ b/packages/business/__tests__/inbox-with-integrations-relations.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, expect, test, vi } from "vitest" +// Deep relative import on purpose: `@chatbotx.io/database` publishes no +// `./relations` subpath (the relation parts are internal to `client.ts`), and +// this test's whole point is to compare against the *actual* definition rather +// than a copy of it. `packages/business/tsconfig.json` only type-checks +// `src/**`, so reaching across the package boundary here does not leak into the +// published build. +import { inboxRelations } from "../../database/src/relations/inbox" + +/** + * `InboxService.withIntegrations` is passed straight to Drizzle's `with:` + * clause. A key listed there but missing from `inboxRelations.inboxModel` + * compiles fine — `InboxWithIntegrations` is a hand-written type, so TypeScript + * has no idea the relation is undefined — and then blows up at runtime with + * `TypeError: Cannot read properties of undefined (reading 'targetTable')`, + * taking down every page that loads an inbox with its integrations. + * + * Both sides are read from their real definitions (no hardcoded channel list), + * so adding a channel to one without the other fails here. + */ + +const findManyMock = vi.fn() +const findFirstMock = vi.fn() + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn((...args: unknown[]) => ({ and: args })), + db: { + $count: vi.fn().mockResolvedValue(0), + query: { + inboxModel: { + findFirst: findFirstMock, + findMany: findManyMock, + }, + }, + }, + eq: vi.fn((column: unknown, value: unknown) => ({ eq: [column, value] })), + ne: vi.fn((column: unknown, value: unknown) => ({ ne: [column, value] })), + relationsFilterToSQL: vi.fn(() => ({})), +})) + +vi.mock("../src/base.service", () => ({ + BaseService: class {}, +})) + +vi.mock("../src/logger", () => ({ + logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})) + +vi.mock("../src/quota-enforcement/service", () => ({ + quotaEnforcementService: { consume: vi.fn(), release: vi.fn() }, +})) + +vi.mock("../src/workspace-usage/service", () => ({ + workspaceUsageService: { decrement: vi.fn(), increment: vi.fn() }, +})) + +/** + * `defineRelationsPart(schema, ...)` returns + * `{ [tableKey]: { table, name, relations } }`, so the relation names a table + * actually defines live under `.relations`. Nothing in drizzle's public types + * exposes that map, hence the cast. + */ +type RelationsPart = Record }> + +const definedInboxRelations = new Set( + Object.keys( + (inboxRelations as unknown as RelationsPart).inboxModel.relations, + ), +) + +const captureWithClause = async ( + call: (service: { + findWithIntegrationsById: (props: { id: string }) => Promise + listWithIntegrationsByWorkspace: (workspaceId: string) => Promise + }) => Promise, + mock: typeof findManyMock, +): Promise> => { + const { inboxService } = await import("../src/inbox/service") + await call(inboxService) + + const [args] = mock.mock.calls.at(-1) as [{ with?: Record }] + const withClause = args.with + expect(withClause).toBeDefined() + return withClause as Record +} + +beforeEach(() => { + vi.clearAllMocks() + findManyMock.mockResolvedValue([]) + findFirstMock.mockResolvedValue(undefined) +}) + +test("every relation listWithIntegrationsByWorkspace eager-loads is defined on inboxModel", async () => { + const withClause = await captureWithClause( + (service) => service.listWithIntegrationsByWorkspace("workspace-1"), + findManyMock, + ) + + const undefinedRelations = Object.keys(withClause).filter( + (relation) => !definedInboxRelations.has(relation), + ) + + expect( + undefinedRelations, + `InboxService.withIntegrations references relations that packages/database/src/relations/inbox.ts does not define: ${undefinedRelations.join(", ")}. Drizzle throws "Cannot read properties of undefined (reading 'targetTable')" at runtime for these.`, + ).toEqual([]) +}) + +test("every relation findWithIntegrationsById eager-loads is defined on inboxModel", async () => { + const withClause = await captureWithClause( + (service) => service.findWithIntegrationsById({ id: "inbox-1" }), + findFirstMock, + ) + + const undefinedRelations = Object.keys(withClause).filter( + (relation) => !definedInboxRelations.has(relation), + ) + + expect(undefinedRelations).toEqual([]) +}) + +test("withIntegrations covers every integration relation inboxModel defines", async () => { + // Reverse direction: without this, dropping a channel from + // `withIntegrations` would silently shrink the matrices that + // `workspace-lifecycle.channel-switch-exhaustive.test.ts` derives from it, + // and both suites would go green while the channel stopped being loaded. + const withClause = await captureWithClause( + (service) => service.listWithIntegrationsByWorkspace("workspace-1"), + findManyMock, + ) + + const definedIntegrationRelations = [...definedInboxRelations] + .filter((relation) => relation.startsWith("integration")) + .sort() + + expect(definedIntegrationRelations).toEqual( + Object.keys(withClause) + .filter((relation) => relation.startsWith("integration")) + .sort(), + ) +}) diff --git a/packages/business/__tests__/workspace-lifecycle.channel-switch-exhaustive.test.ts b/packages/business/__tests__/workspace-lifecycle.channel-switch-exhaustive.test.ts new file mode 100644 index 0000000000..e7ea77c94e --- /dev/null +++ b/packages/business/__tests__/workspace-lifecycle.channel-switch-exhaustive.test.ts @@ -0,0 +1,170 @@ +import { channelTypes } from "@chatbotx.io/database/partials" +// biome-ignore lint/performance/noNamespaceImport: the schema barrel is indexed by model name to derive the per-channel matrix +import * as schema from "@chatbotx.io/database/schema" +import { beforeEach, expect, test, vi } from "vitest" +// Deep relative import on purpose: `@chatbotx.io/database` publishes no +// `./relations` subpath. See the same note in +// `inbox-with-integrations-relations.test.ts`, which pins these relation names +// to be exactly the set `InboxService.withIntegrations` eager-loads — so this +// matrix cannot silently shrink. +import { inboxRelations } from "../../database/src/relations/inbox" + +/** + * `disconnectWorkspaceInbox` switches on `inbox.channel` and deletes that + * channel's integration row. It ends in a `default:` branch, so a channel with + * no `case` compiles cleanly and silently leaks its `Integration*` row on + * workspace deletion and trial-expiry teardown (`inboxService.disconnect` only + * flips `Inbox.status`, it never deletes the inbox row, so nothing cascades). + * + * The matrix is derived, never hardcoded: every `integration*` relation defined + * on `inboxModel` must have a case that deletes the matching schema model. + * + * Not covered here, deliberately: `api`. `IntegrationApi` has an `inboxId` like + * the others, but it has no `integration*` relation on `inboxModel` and is not + * in `InboxService.withIntegrations`, so teardown never loads it and no `case` + * could act on it. That is a pre-existing gap in the teardown itself, not + * something a `case` here would fix. + */ + +const listWithIntegrationsByWorkspaceMock = vi.fn() +const disconnectInboxMock = vi.fn() + +vi.mock("@chatbotx.io/database/client", () => ({ + and: vi.fn((...args: unknown[]) => ({ and: args })), + db: {}, + eq: vi.fn((column: unknown, value: unknown) => ({ eq: [column, value] })), + inArray: vi.fn((column: unknown, values: unknown) => ({ + inArray: [column, values], + })), + liftDecompressionLimit: vi.fn(), + sql: vi.fn(), +})) + +vi.mock("@chatbotx.io/sequence-scheduler/dispatch-cancel", () => ({ + cancelPendingDispatchesForWorkspace: vi.fn().mockResolvedValue([]), + removeDispatchesFromSchedule: vi.fn(), +})) + +vi.mock("../src/base.service", () => ({ + BaseService: class {}, +})) + +vi.mock("../src/workspace-lifecycle/campaign-cleanup", () => ({ + cancelInFlightBroadcastsForWorkspace: vi.fn(), + completeActiveSequenceEnrollmentsForWorkspace: vi.fn(), +})) + +vi.mock("../src/workspace-lifecycle/smart-delay-cleanup", () => ({ + cancelSmartDelaysForWorkspace: vi.fn(), +})) + +vi.mock("../src/inbox/service", () => ({ + inboxService: { + disconnect: disconnectInboxMock, + listWithIntegrationsByWorkspace: listWithIntegrationsByWorkspaceMock, + }, +})) + +vi.mock("../src/coexist/service", () => ({ + coexistService: { + tearDownForIntegration: vi.fn(), + }, +})) + +vi.mock("../src/logger", () => ({ + logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})) + +type RelationsPart = Record }> + +const toChannel = (relation: string): string => { + const suffix = relation.slice("integration".length) + return `${suffix.charAt(0).toLowerCase()}${suffix.slice(1)}` +} + +const channelsWithInboxIntegration = Object.keys( + (inboxRelations as unknown as RelationsPart).inboxModel.relations, +) + .filter((relation) => relation.startsWith("integration")) + .map((relation) => ({ + channel: toChannel(relation), + modelName: `${relation}Model`, + property: relation, + })) + .sort((a, b) => a.channel.localeCompare(b.channel)) + +const modelFor = (modelName: string): unknown => + (schema as unknown as Record)[modelName] + +beforeEach(() => { + vi.clearAllMocks() + listWithIntegrationsByWorkspaceMock.mockResolvedValue([]) + disconnectInboxMock.mockResolvedValue(undefined) +}) + +test("the derived channel matrix is non-empty and only contains real channels", () => { + expect(channelsWithInboxIntegration.length).toBeGreaterThan(0) + + for (const { channel, modelName } of channelsWithInboxIntegration) { + expect(channelTypes.options, `${channel} is not a ChannelType`).toContain( + channel, + ) + expect( + modelFor(modelName), + `${modelName} is missing from the schema`, + ).toBeDefined() + } +}) + +test.each( + channelsWithInboxIntegration, +)("disconnectWorkspaceChannels deletes the $channel integration row instead of falling through to default", async ({ + channel, + modelName, + property, +}) => { + const deleteWhereMock = vi.fn().mockResolvedValue(undefined) + const deleteMock = vi.fn(() => ({ where: deleteWhereMock })) + const updateWhereMock = vi.fn().mockResolvedValue(undefined) + const updateMock = vi.fn(() => ({ + set: vi.fn(() => ({ where: updateWhereMock })), + })) + const tx = { delete: deleteMock, update: updateMock } + + listWithIntegrationsByWorkspaceMock.mockResolvedValue([ + { + id: `inbox-${channel}`, + channel, + workspaceId: "workspace-1", + [property]: { + id: `integration-${channel}`, + auth: { tokens: { accessToken: "token" } }, + // Extra fields a few branches read before deleting; harmless + // everywhere else, and keeping the stub uniform is what lets the + // matrix stay derived instead of hand-written per channel. + phoneNumberId: "phone-1", + type: "facebook", + }, + }, + ]) + + const { workspaceLifecycleService } = await import( + "../src/workspace-lifecycle/service" + ) + + await workspaceLifecycleService.disconnectWorkspaceChannels({ + workspaceId: "workspace-1", + ownerId: "owner-1", + teardownLevel: "disconnect", + tx: tx as never, + }) + + const deletedModels = deleteMock.mock.calls.map(([model]) => model) + expect( + deletedModels, + `disconnectWorkspaceInbox has no "${channel}" case, so its ${modelName} rows survive workspace teardown`, + ).toContain(modelFor(modelName)) + expect(disconnectInboxMock).toHaveBeenCalledWith( + expect.objectContaining({ inboxId: `inbox-${channel}` }), + ) +}) diff --git a/packages/variables/__tests__/integration-fields-channel-coverage.test.ts b/packages/variables/__tests__/integration-fields-channel-coverage.test.ts new file mode 100644 index 0000000000..2729ca56ee --- /dev/null +++ b/packages/variables/__tests__/integration-fields-channel-coverage.test.ts @@ -0,0 +1,177 @@ +import { channelTypes } from "@chatbotx.io/database/partials" +import type { + ContactInboxModel, + ContactModel, +} from "@chatbotx.io/database/types" +import { beforeEach, expect, test, vi } from "vitest" +// Deep relative import on purpose: `@chatbotx.io/database` publishes no +// `./relations` subpath, and `InboxWithIntegrations` is a type (erased at +// runtime), so the relation definitions are the only executable source of +// "which integrations an inbox can actually carry". `packages/business`'s +// `inbox-with-integrations-relations.test.ts` pins that set to be exactly what +// `InboxService.withIntegrations` eager-loads. +import { inboxRelations } from "../../database/src/relations/inbox" + +/** + * `getChannelIntegrationId` and the `page_user_name` branch in + * `integration-fields.ts` are per-channel switches that end in `default: return + * null`. A channel with no `case` therefore resolves to `null` forever — the + * `me` link cannot be built and `{{page_user_name}}` renders empty — with no + * compile error and no runtime warning. + * + * The matrix is derived from the inbox relation definitions, not hardcoded, so + * a channel added without threading it through both switches fails here. + * + * `api` is out of scope by construction: it has no `integration*` relation on + * `inboxModel`, so `InboxWithIntegrations` never carries an `integrationApi` + * row for these switches to read. + */ + +const { + mockFindRecentByContactId, + mockFindWithIntegrationsById, + mockResolveTenantSettings, + mockResolveWorkspaceAppUrl, + mockSignMeLink, + mockSystemFieldCreate, +} = vi.hoisted(() => ({ + mockFindRecentByContactId: vi.fn(), + mockFindWithIntegrationsById: vi.fn(), + mockResolveTenantSettings: vi.fn(), + mockResolveWorkspaceAppUrl: vi.fn(), + mockSignMeLink: vi.fn(), + mockSystemFieldCreate: vi.fn(), +})) + +vi.mock("@chatbotx.io/business", () => ({ + contactInboxService: { + findRecentByContactId: mockFindRecentByContactId, + }, + inboxService: { + findWithIntegrationsById: mockFindWithIntegrationsById, + }, + resolveTenantSettings: mockResolveTenantSettings, + resolveWorkspaceAppUrl: mockResolveWorkspaceAppUrl, +})) + +vi.mock("@chatbotx.io/business/contact-locale", () => ({ + normalizeStoredTimezone: (value: string) => value, +})) + +vi.mock("@chatbotx.io/business/system-field", () => ({ + systemFieldService: { create: mockSystemFieldCreate }, +})) + +vi.mock("@chatbotx.io/encryption/link-signature", () => ({ + signMeLink: mockSignMeLink, +})) + +vi.mock("@chatbotx.io/integration-instagram", () => ({ + fetchInstagramContactProfile: vi.fn(), + getPostDetails: vi.fn(), +})) + +vi.mock("@chatbotx.io/integration-messenger", () => ({ + getPostDetails: vi.fn(), + getUserInboxLink: vi.fn(), +})) + +vi.mock("@chatbotx.io/redis", () => ({ + withCache: vi.fn((_key: string, resolve: () => Promise) => + resolve(), + ), +})) + +const { getIntegrationField } = await import( + "../src/helpers/integration-fields" +) + +type RelationsPart = Record }> + +const toChannel = (relation: string): string => { + const suffix = relation.slice("integration".length) + return `${suffix.charAt(0).toLowerCase()}${suffix.slice(1)}` +} + +const channelsWithInboxIntegration = Object.keys( + (inboxRelations as unknown as RelationsPart).inboxModel.relations, +) + .filter((relation) => relation.startsWith("integration")) + .map((relation) => ({ channel: toChannel(relation), property: relation })) + .sort((a, b) => a.channel.localeCompare(b.channel)) + +const contact = { + id: "contact-1", + workspaceId: "workspace-1", + timezone: "UTC", +} as ContactModel + +const buildContactInbox = (channel: string) => + ({ + id: "contact-inbox-1", + channel, + inboxId: "inbox-1", + sourceId: "source-1", + }) as ContactInboxModel + +beforeEach(() => { + vi.clearAllMocks() + mockResolveTenantSettings.mockResolvedValue({ + appUrl: "https://app.example.test", + }) + mockResolveWorkspaceAppUrl.mockResolvedValue("https://app.example.test") + mockSystemFieldCreate.mockResolvedValue({ id: "system-field-1" }) + mockSignMeLink.mockReturnValue("signature") +}) + +test("the derived channel matrix is non-empty and only contains real channels", () => { + expect(channelsWithInboxIntegration.length).toBeGreaterThan(0) + + for (const { channel } of channelsWithInboxIntegration) { + expect(channelTypes.options, `${channel} is not a ChannelType`).toContain( + channel, + ) + } +}) + +test.each( + channelsWithInboxIntegration, +)("page_user_name resolves the $channel integration name", async ({ + channel, + property, +}) => { + mockFindWithIntegrationsById.mockResolvedValue({ + id: "inbox-1", + [property]: { id: `integration-${channel}`, name: `Name ${channel}` }, + }) + + await expect( + getIntegrationField(contact, "page_user_name", buildContactInbox(channel)), + ).resolves.toBe(`Name ${channel}`) +}) + +test.each( + channelsWithInboxIntegration, +)("getChannelIntegrationId resolves the $channel integration id for the me link", async ({ + channel, + property, +}) => { + mockFindWithIntegrationsById.mockResolvedValue({ + id: "inbox-1", + [property]: { id: `integration-${channel}`, name: `Name ${channel}` }, + }) + + const link = await getIntegrationField( + contact, + "me", + buildContactInbox(channel), + ) + + expect( + link, + `getChannelIntegrationId has no "${channel}" case, so the me link cannot be built for it`, + ).not.toBeNull() + expect(new URL(link as string).searchParams.get("ib")).toBe( + `integration-${channel}`, + ) +}) From 88a8e63c126528fd8093517313c44bb0168dc909 Mon Sep 17 00:00:00 2001 From: alansyue Date: Tue, 1 Sep 2026 17:41:49 +0800 Subject: [PATCH 7/7] fix(threads): reconcile with upstream main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge is textually clean but breaks in two ways that only a type check or a running build surfaces. Two `Record` maps landed upstream after this branch last merged and neither has a threads entry, so both fail exhaustiveness: `errorLogProviderLabels` (#1054) and `contactProfileNameCapabilities` (#1074). Threads is a comment-only channel — no message webhook parses an `IncomingContact` and the integration exposes no `contact.getProfile` handler — so its profile capabilities are `{ inbound: null, onDemand: false }`. Upstream also renamed `features/common/schemas` to `features/common/schema`. Four threads files still imported the old path, which left the builder unable to compile the routes that import them; the webhook endpoint answered 500 until this was fixed. --- .../features/integration-threads/actions/disconnect.action.ts | 2 +- .../threads-comments/actions/create-threads-comment.action.ts | 2 +- .../threads-comments/actions/delete-threads-comment.action.ts | 2 +- .../threads-comments/actions/update-threads-comment.action.ts | 2 +- packages/business/__tests__/contact-profile-refresh.test.ts | 1 + packages/business/src/contact/profile-refresh/rules.ts | 1 + packages/utils/src/error-log.ts | 1 + 7 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/builder/src/features/integration-threads/actions/disconnect.action.ts b/apps/builder/src/features/integration-threads/actions/disconnect.action.ts index 9551dd59f3..86a58577d6 100644 --- a/apps/builder/src/features/integration-threads/actions/disconnect.action.ts +++ b/apps/builder/src/features/integration-threads/actions/disconnect.action.ts @@ -3,7 +3,7 @@ import { type WorkspaceIdAndIdRequestParams, workspaceIdAndIdRequestParams, -} from "@/features/common/schemas" +} from "@/features/common/schema" import { workspaceActionClientAllowExpired } from "@/lib/safe-action" import { disconnectThreads } from "./disconnect" 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 index f6d9983286..46c129b53e 100644 --- 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 @@ -4,7 +4,7 @@ import { fbCommentAutomationService } from "@chatbotx.io/business" import { type WorkspaceIdRequestParams, workspaceIdrequestParams, -} from "@/features/common/schemas/index" +} from "@/features/common/schema" import { workspaceActionClient } from "@/lib/safe-action" import { type CreateThreadsCommentRequest, 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 index 862ded51d2..a10da0fae1 100644 --- 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 @@ -4,7 +4,7 @@ import { fbCommentAutomationService } from "@chatbotx.io/business" import { type WorkspaceIdAndIdRequestParams, workspaceIdAndIdRequestParams, -} from "@/features/common/schemas/index" +} from "@/features/common/schema" import { workspaceActionClientAllowExpired } from "@/lib/safe-action" export const deleteThreadsCommentAction = workspaceActionClientAllowExpired 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 index 0753d1a1ea..69f89a6c69 100644 --- 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 @@ -4,7 +4,7 @@ import { fbCommentAutomationService } from "@chatbotx.io/business" import { type WorkspaceIdAndIdRequestParams, workspaceIdAndIdRequestParams, -} from "@/features/common/schemas/index" +} from "@/features/common/schema" import { workspaceActionClient } from "@/lib/safe-action" import { type UpdateThreadsCommentRequest, diff --git a/packages/business/__tests__/contact-profile-refresh.test.ts b/packages/business/__tests__/contact-profile-refresh.test.ts index 3f71f895be..754a85c9cc 100644 --- a/packages/business/__tests__/contact-profile-refresh.test.ts +++ b/packages/business/__tests__/contact-profile-refresh.test.ts @@ -266,6 +266,7 @@ describe("capability table", () => { whatsapp: "payload", api: "payload", tiktok: null, + threads: null, webchat: null, smtp: null, omnichannel: null, diff --git a/packages/business/src/contact/profile-refresh/rules.ts b/packages/business/src/contact/profile-refresh/rules.ts index 3cfff05be9..23a188f5c8 100644 --- a/packages/business/src/contact/profile-refresh/rules.ts +++ b/packages/business/src/contact/profile-refresh/rules.ts @@ -32,6 +32,7 @@ export const contactProfileNameCapabilities = { telegram: { inbound: "channelApi", onDemand: true }, // getChat keyed by the contact's chat id — identity-safe (payload names a clicking user, not the chat) whatsapp: { inbound: "payload", onDemand: false }, // contacts[0].profile.name; no user-profile API tiktok: { inbound: null, onDemand: false }, // webhook carries ids only, no profile API + threads: { inbound: null, onDemand: false }, // comment-only channel: no message webhook parses an IncomingContact, and the integration exposes no contact.getProfile handler api: { inbound: "payload", onDemand: false }, webchat: { inbound: null, onDemand: false }, smtp: { inbound: null, onDemand: false }, diff --git a/packages/utils/src/error-log.ts b/packages/utils/src/error-log.ts index 2a4d5aeb53..6396e569cf 100644 --- a/packages/utils/src/error-log.ts +++ b/packages/utils/src/error-log.ts @@ -95,6 +95,7 @@ export const errorLogProviderLabels = { smtp: "Email", telegram: "Telegram", instagram: "Instagram", + threads: "Threads", tiktok: "TikTok", api: "API", // meta platform surfaces