Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Channel vs integration.** Not every integration is a channel. `channelTypes`
(`packages/utils/src/channel.ts:18`) is exactly: `omnichannel`, `webchat`, `messenger`,
`whatsapp`, `zalo`, `smtp`, `telegram`, `instagram`, `threads`, `tiktok`, `api`. Entries below that are
`whatsapp`, `zalo`, `line`, `smtp`, `telegram`, `instagram`, `threads`, `tiktok`, `api`. Entries below that are
not in that list (`google-sheets`, `instagram-facebook`, …) are `integrationTypes` only —
they connect an external service but carry no inbox conversation.

Expand All @@ -12,6 +12,7 @@ they connect an external service but carry no inbox conversation.
| messenger | OAuth2 | YES | clientId/clientSecret as platform credential |
| whatsapp | OAuth2 | YES | clientId/clientSecret + systemUser as platform credential |
| zalo | OAuth2 | YES | clientId/clientSecret as platform credential |
| line | Custom | NO | Per-workspace Channel ID + Channel secret (no platform credential); 30-day short-lived v2 token minted from client credentials; HMAC `x-line-signature` webhook; reply-then-push sending; 1:1 only. See `docs/line.md` |
| tiktok | OAuth2 | YES | clientId/clientSecret as platform credential |
| threads | OAuth2 | YES | Meta Threads app (clientId/clientSecret) as platform credential; comments only — public reply + hide top-level replies, no DM API and no like; see the `comment-automation` skill |
| google-sheets | OAuth2 | YES | clientId/clientSecret as platform credential |
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ See **`.agents/rules/git.md`** for the full canonical rules (commit format, bran
- Trigger sequence actions (`addSequence` / `removeSequence`: shape, no sequence events, template behavior) and trigger loop protection (non-emitting actions, per-trigger-per-contact run circuit breaker, blind spots): `docs/trigger-actions.md`
- Bot block trigger events (`contactBlockedBot` / `contactUnblockedBot`: Telegram `my_chat_member` source, existing contacts only, retry dedupe, re-registering older bots' webhooks with `backfill:telegram-webhooks`): `docs/bot-block-trigger-events.md`
- Comment automation (all channels): `docs/comment-automation.md` (skill: `.agents/skills/comment-automation/`)
- LINE channel (Messaging API setup, webhook + signature, reply vs push and monthly quota, token refresh, flow-step mapping, ops runbook, open items): `docs/line.md`
- Push notifications (Expo Push Service, device tokens): `docs/push-notifications.md`
- Enterprise licensing (offline Ed25519 license keys): `docs/licensing.md`
- Platform support access (super admin opening any workspace): `docs/support-access.md`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2227,6 +2227,31 @@ exports[`public API spec — operation naming guard > operation list (operationI
"operationId": "leaderboards.update",
"path": "/v1/leaderboards/{id}",
},
{
"method": "POST",
"operationId": "lineChannels.connect",
"path": "/v1/line-channels",
},
{
"method": "DELETE",
"operationId": "lineChannels.delete",
"path": "/v1/line-channels/{id}",
},
{
"method": "GET",
"operationId": "lineChannels.get",
"path": "/v1/line-channels/{id}",
},
{
"method": "GET",
"operationId": "lineChannels.list",
"path": "/v1/line-channels",
},
{
"method": "POST",
"operationId": "lineChannels.testWebhook",
"path": "/v1/line-channels/{id}/test-webhook",
},
{
"method": "POST",
"operationId": "magicLinks.create",
Expand Down
3 changes: 3 additions & 0 deletions apps/builder/__tests__/api-parity-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@
"integration-openrouter/update.action:updateIntegrationOpenRouterAction": "private:no public surface yet (UI-only, not yet audited)",
"integration-sendgrid/connect.action:connectSendGridAction": "private:no public surface yet (UI-only, not yet audited)",
"integration-sendgrid/disconnect.action:disconnectSendGridAction": "private:no public surface yet (UI-only, not yet audited)",
"integration-line/connect.action:connectLineAction": "covered:integration-line",
"integration-line/disconnect.action:disconnectLineAction": "covered:integration-line",
"integration-line/test-webhook.action:testLineWebhookAction": "covered:integration-line",
"integration-smtp/create-smtp.action:createSmtpAction": "covered:integration-smtp",
"integration-smtp/delete-smtp.action:deleteSmtpAction": "covered:integration-smtp",
"integration-smtp/update-smtp.action:updateSmtpAction": "covered:integration-smtp",
Expand Down
258 changes: 258 additions & 0 deletions apps/builder/__tests__/connect-line.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest"

const m = vi.hoisted(() => {
const order: string[] = []
return {
order,
runAction: vi.fn((name: string) => {
order.push(name)
if (name === "connect") {
return Promise.resolve({ auth: { a: 1 }, bot: { displayName: "Bot" } })
}
if (name === "registerWebhook") {
return Promise.resolve({ previousWebhookUrl: null })
}
return Promise.resolve({ success: true, active: true })
}),
connect: vi.fn(),
updateAuth: vi.fn(),
markTokenRefreshError: vi.fn(async () => undefined),
resolveVisibleChannels: vi.fn(() => Promise.resolve(["line"])),
resolveChannelPolicy: vi.fn(() =>
Promise.resolve({ visibleChannels: ["line"] }),
),
refreshAuth: vi.fn(),
}
})
vi.mock("@/integration", () => ({
integrations: {
line: { runAction: m.runAction, refreshAuth: m.refreshAuth },
},
}))
vi.mock("@/env", () => ({ isCloud: () => false }))
vi.mock("@/lib/log", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}))
vi.mock("@/lib/oauth-broker", () => ({
buildBrokerCallbackUrl: (p: string) => `https://b${p}`,
}))
vi.mock("@chatbotx.io/business/audit", () => ({
auditService: { record: vi.fn() },
}))
vi.mock("@chatbotx.io/business", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
hasWorkspaceAccess: vi.fn(async () => true),
userQuotaService: {},
workspaceService: { findOrFail: vi.fn(async () => ({ ownerId: "owner1" })) },
lineIntegrationService: {
connect: m.connect,
updateAuth: m.updateAuth,
markTokenRefreshError: m.markTokenRefreshError,
},
tenantService: { resolveVisibleChannels: m.resolveVisibleChannels },
}))
vi.mock("@/lib/workspace/resolve-visible-channels", () => ({
resolveChannelPolicy: m.resolveChannelPolicy,
}))

import {
checkConnectedLineWebhook,
connectLine,
ensureFreshLineAuth,
} from "@/features/integration-line/lib/connect-line"

describe("connectLine", () => {
test("runs the webhook test after the connect transaction commits", async () => {
m.connect.mockImplementation(async (input: any) => {
await input.onConnected({ integrationId: "1" })
m.order.push("committed")
return {
workspaceId: "w1",
createdWorkspace: false,
wasCreated: false,
integrationId: "1",
inboxId: "2",
}
})
const result = await connectLine({
workspaceId: "w1",
user: null,
channelId: "123",
channelSecret: "s",
})
expect(m.order).toEqual([
"connect",
"registerWebhook",
"committed",
"testWebhook",
])
expect(m.connect.mock.calls[0]?.[0]).toMatchObject({ createdBy: "owner1" })
expect(result.webhook).toMatchObject({ active: true, testOk: true })
})

test("maps a LINE 401 to connectionCredentialsRejected", async () => {
const { SdkException } = await import("@chatbotx.io/sdk")
m.runAction.mockRejectedValueOnce(new SdkException("bad", "lineError", 401))
await expect(
connectLine({
workspaceId: "w1",
user: null,
channelId: "1",
channelSecret: "s",
}),
).rejects.toMatchObject({ code: "connectionCredentialsRejected" })
})

test("wraps an unknown error in a generic ChatbotXException", async () => {
m.runAction.mockRejectedValueOnce(new Error("boom"))
await expect(
connectLine({
workspaceId: "w1",
user: null,
channelId: "1",
channelSecret: "s",
}),
).rejects.toMatchObject({
message: expect.stringContaining("Failed to connect LINE"),
})
})

test("refuses a tenant-hidden LINE channel (workspace)", async () => {
m.resolveChannelPolicy.mockResolvedValueOnce({
visibleChannels: ["telegram"],
})
await expect(
connectLine({
workspaceId: "w1",
user: null,
channelId: "1",
channelSecret: "s",
}),
).rejects.toMatchObject({ code: "channelHidden" })
expect(m.runAction).not.toHaveBeenCalled()
})

test("refuses a tenant-hidden LINE channel (new workspace)", async () => {
m.resolveVisibleChannels.mockResolvedValueOnce(["telegram"])
await expect(
connectLine({
user: { id: "u1", email: "a@b.c" } as never,
channelId: "1",
channelSecret: "s",
}),
).rejects.toMatchObject({ code: "channelHidden" })
})

test("user null without workspaceId is a 404", async () => {
await expect(
connectLine({ user: null, channelId: "1", channelSecret: "s" }),
).rejects.toMatchObject({ code: "notFound", httpStatusCode: 404 })
})

test("a 4xx while registering the webhook is lineWebhookRejected", async () => {
const { SdkException } = await import("@chatbotx.io/sdk")
m.connect.mockImplementationOnce(async (input: any) => {
m.runAction.mockRejectedValueOnce(
new SdkException("bad url", "lineError", 400),
)
await input.onConnected({ integrationId: "1" })
})
await expect(
connectLine({
workspaceId: "w1",
user: null,
channelId: "1",
channelSecret: "s",
}),
).rejects.toMatchObject({ code: "lineWebhookRejected" })
})

test("a timeout maps to connectionProviderUnavailable", async () => {
const err = new Error("timed out")
err.name = "TimeoutError"
m.runAction.mockRejectedValueOnce(err)
await expect(
connectLine({
workspaceId: "w1",
user: null,
channelId: "1",
channelSecret: "s",
}),
).rejects.toMatchObject({
code: "connectionProviderUnavailable",
httpStatusCode: 502,
})
})

test("a fetch TypeError maps to connectionProviderUnavailable", async () => {
m.runAction.mockRejectedValueOnce(new TypeError("fetch failed"))
await expect(
connectLine({
workspaceId: "w1",
user: null,
channelId: "1",
channelSecret: "s",
}),
).rejects.toMatchObject({ code: "connectionProviderUnavailable" })
})

test("ensureFreshLineAuth refreshes an expired token and persists it", async () => {
const fresh = {
tokens: { expiresAt: new Date(Date.now() + 1e9).toISOString() },
}
m.refreshAuth.mockResolvedValueOnce(fresh)
const out = await ensureFreshLineAuth({
id: "1",
workspaceId: "w1",
auth: {
tokens: { expiresAt: new Date(Date.now() - 1000).toISOString() },
},
})
expect(out).toBe(fresh)
expect(m.updateAuth).toHaveBeenCalledWith({
id: "1",
workspaceId: "w1",
auth: fresh,
})
})

test("checkConnectedLineWebhook reports a revoked-credentials refresh failure and marks the channel offline", async () => {
const { AuthException } = await import("@chatbotx.io/sdk")
m.refreshAuth.mockRejectedValueOnce(
new AuthException("invalid_client", "lineAuthError", 400),
)
const out = await checkConnectedLineWebhook({
id: "1",
workspaceId: "w1",
auth: {
tokens: { expiresAt: new Date(Date.now() - 1000).toISOString() },
},
})
expect(out).toEqual({
active: false,
testOk: false,
testReason: "invalid_client",
})
expect(m.markTokenRefreshError).toHaveBeenCalledWith({
id: "1",
workspaceId: "w1",
error: "invalid_client",
isRevoked: true,
})
})

test("checkConnectedLineWebhook does not mark offline on a transient refresh failure", async () => {
m.markTokenRefreshError.mockClear()
m.refreshAuth.mockRejectedValueOnce(new Error("network down"))
const out = await checkConnectedLineWebhook({
id: "1",
workspaceId: "w1",
auth: {
tokens: { expiresAt: new Date(Date.now() - 1000).toISOString() },
},
})
expect(out).toMatchObject({ testOk: false, testReason: "network down" })
expect(m.markTokenRefreshError).not.toHaveBeenCalled()
})
})
47 changes: 47 additions & 0 deletions apps/builder/__tests__/line-list-queries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest"

const row = {
id: "10",
workspaceId: "w1",
inboxId: "20",
channelId: "123",
name: "Shop",
basicId: "@shop",
pictureUrl: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
tokenRefreshError: "secret-ish",
auth: {
clientId: "123",
clientSecret: "SECRET-VALUE",
tokens: { accessToken: "TOKEN-VALUE", expiresAt: "2026-02-01T00:00:00Z" },
},
}

vi.mock("@chatbotx.io/business", () => ({
lineIntegrationService: { listByWorkspace: vi.fn(async () => [row]) },
}))

import { listIntegrationLines } from "@/features/integration-line/queries"

describe("listIntegrationLines", () => {
test("returns only the public projection (no auth, secret or token)", async () => {
const { data } = await listIntegrationLines({
where: { workspaceId: "w1" },
})
expect(data).toHaveLength(1)
expect(Object.keys(data[0] ?? {}).sort()).toEqual([
"basicId",
"channelId",
"createdAt",
"id",
"inboxId",
"name",
"pictureUrl",
])
const serialized = JSON.stringify(data)
expect(serialized).not.toContain("SECRET-VALUE")
expect(serialized).not.toContain("TOKEN-VALUE")
expect(serialized).not.toContain("auth")
})
})
Loading
Loading