Skip to content

feat(channels): add LINE, Viber, and Meta Threads messaging channels - #3

Merged
JOY (JOY) merged 2 commits into
devfrom
feat/channels-line-viber-threads
Sep 8, 2026
Merged

feat(channels): add LINE, Viber, and Meta Threads messaging channels#3
JOY (JOY) merged 2 commits into
devfrom
feat/channels-line-viber-threads

Conversation

@JOY

@JOY JOY (JOY) commented Sep 7, 2026

Copy link
Copy Markdown

Summary

Adds three new omnichannel messaging channels following the existing channel integration architecture (models -> repositories -> services -> handlers -> builders):

  • LINE Official Account - Messaging API push messages, X-Line-Signature HMAC-SHA256 (base64) webhook verification
  • Viber Business Bot - send_message API, X-Viber-Content-Signature HMAC-SHA256 (hex) verification, conversation_started welcome message response
  • Meta Threads - Graph API two-step publish (reply_to_id anchored to the latest customer media id), Meta-style hub.challenge GET verification + X-Hub-Signature-256 POST verification, supports both documented webhook envelope shapes

Each channel ships: REST client + tests, inbound webhook service + tests (identity mapping, conversation dedupe, message persistence), async outbox delivery (batch 20 / max retry 5 / linear backoff) wired into cron and message service hooks, third-party webhook routes under /api/third/{line,viber,threads}, dashboard create/edit forms with icons, and i18n for en-US / zh-CN / vi-VN.

Also backfills the shared ChannelIcon component with missing icons for whatsapp / slack / x / tiktok.

Not included (deliberate)

  • KakaoTalk: the public KakaoTalk Channel API only exposes add/block webhooks; 1:1 consultation messaging requires a contracted aggregator (Infobank / Happytalk / Bizmessage). Deferred until an aggregator contract defines the webhook format.
  • Threads OAuth 1-Click connect: token is configured manually for now (same style as Discord manual config); OAuth can be added later following the existing channel_oauth_handler pattern.

Test plan

  • go build ./...
  • go test ./... - all packages pass (pre-existing unrelated failure TestBuildLightweightTicket in internal/builders reproduces on a clean baseline; verified via git stash -u)
  • New tests: internal/line, internal/viber, internal/threads client + signature tests; TestLineInboundAndOutbound, TestViberInboundAndOutbound, TestThreadsInboundAndOutbound service tests
  • pnpm typecheck
  • pnpm lint - no new issues (7 pre-existing errors in unrelated files)
  • task enums regenerated (ExternalSource.Threads added to generated enums)
  • Browser verification of the Channels UI (not yet performed - no running environment; to be done after deploy)

- REST API clients (internal/line, internal/viber, internal/threads) with webhook
  signature verification (HMAC-SHA256 base64 for LINE, hex for Viber, sha256 for Meta)
- Inbound services mapping external identity, conversation dedupe, and customer
  message persistence per channel
- Async outbox delivery via ChannelMessageOutbox with batch 20 / max retry 5 /
  linear backoff, wired into cron and message service hooks
- Third-party webhook endpoints under /api/third/{line,viber,threads} with
  Meta-style hub.challenge verification for Threads
- Viber conversation_started welcome message response support
- Threads replies anchor to the latest customer media id for two-step publish
- Dashboard channel create/edit forms, icons, and i18n for en-US, zh-CN, vi-VN
@cursor

cursor Bot commented Sep 7, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_0ff4200c-cc2f-4c75-9cff-2ee275efed2a)

@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: f27d3b71-16d3-43db-8c86-1dba904b52cc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for three new messaging channels: LINE, Viber, and Meta Threads, including webhook handlers, inbound/outbound services, database configurations, and frontend UI components. The review feedback highlights several critical issues that need to be addressed: a security vulnerability in the Threads webhook verification when channelID is empty; a threading bug in Threads inbound processing where reply IDs are incorrectly used as customer external IDs; potential race conditions in the LINE, Viber, and Threads outbound dispatchers that could cause duplicate messages; and a frontend issue where editing channels silently deletes configuration fields like avatarUrl and welcomeMessage from the database.

Comment on lines +26 to +37
if mode == "subscribe" {
if channelID != "" {
channel := services.ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeThreads, enums.StatusOk)
if channel != nil {
if cfg, err := services.ChannelService.ParseThreadsChannelConfig(channel.ConfigJSON); err == nil && cfg != nil {
if cfg.WebhookVerifyToken != "" && cfg.WebhookVerifyToken != token {
ctx.String(http.StatusForbidden, "Verification token mismatch")
return
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

If channelID is empty, the verification token check is skipped entirely, and the challenge is returned with a 200 OK status. This allows anyone to verify a subscription without a valid token if they call the endpoint without a channel_id parameter.

To fix this security issue, you should fall back to the default Threads channel when channelID is empty (similar to how it is handled in ThreadsPostWebhook), and enforce token verification.

	if mode == "subscribe" {
		var channel *models.Channel
		if channelID != "" {
			channel = services.ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeThreads, enums.StatusOk)
		} else {
			channel = services.ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeThreads, enums.StatusOk)
		}

		if channel != nil {
			if cfg, err := services.ChannelService.ParseThreadsChannelConfig(channel.ConfigJSON); err == nil && cfg != nil {
				if cfg.WebhookVerifyToken != "" && cfg.WebhookVerifyToken != token {
					ctx.String(http.StatusForbidden, "Verification token mismatch")
					return
				}
			}
		} else {
			ctx.String(http.StatusNotFound, "Channel not found")
			return
		}

		ctx.String(http.StatusOK, challenge)
		return
	}

Comment on lines +90 to +107
externalID := strings.TrimSpace(value.ID)
if externalID == "" {
externalID = strings.TrimSpace(value.MediaID)
}
if externalID == "" {
return nil
}

name := strings.TrimSpace(value.Username)
if name == "" {
name = fmt.Sprintf("Threads User %s", externalID)
}

externalUser := openidentity.ExternalUser{
ExternalSource: enums.ExternalSourceThreads,
ExternalID: externalID,
ExternalName: name,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using value.ID (the reply/media ID) as the customer's ExternalID is a critical bug. Since value.ID is unique for every single reply, every new message from the same user will be treated as a completely new customer, creating a new CustomerIdentity and a new Conversation. This breaks conversation threading and history.

Instead, you should use the customer's unique identifier (such as value.Username if a unique user ID is not provided by the Threads webhook) as the ExternalID so that all replies from the same user are correctly grouped into the same conversation.

	externalID := strings.TrimSpace(value.Username)
	if externalID == "" {
		externalID = strings.TrimSpace(value.ID)
	}
	if externalID == "" {
		return nil
	}

	name := externalID

	externalUser := openidentity.ExternalUser{
		ExternalSource: enums.ExternalSourceThreads,
		ExternalID:     externalID,
		ExternalName:   name,
	}

Comment on lines +83 to +86
// Verify customer identity
identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
Eq("external_source", enums.ExternalSourceThreads).
Eq("external_id", "reply_9001"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since the customer's ExternalID should be mapped to their unique Username (e.g., "threads_customer") rather than the reply ID ("reply_9001"), this assertion needs to be updated to match the correct external_id and keep the tests green.

Suggested change
// Verify customer identity
identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
Eq("external_source", enums.ExternalSourceThreads).
Eq("external_id", "reply_9001"))
// Verify customer identity
identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
Eq("external_source", enums.ExternalSourceThreads).
Eq("external_id", "threads_customer"))

Comment on lines +72 to +77
if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
"send_status": string(enums.ChannelMessageOutboxStatusSending),
"updated_at": time.Now(),
}); err != nil {
return err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a race condition here. Since DispatchPendingOutbox is triggered asynchronously on every message enqueue, multiple goroutines can run concurrently. Both goroutines can fetch the same pending outbox items via ListPending before either has updated their status to sending. As a result, both will pass the SendStatus check and call client.PushMessage, causing duplicate messages to be sent to the customer.

To prevent this, you should use optimistic locking when updating the status to sending (e.g., UPDATE ... WHERE id = ? AND send_status = 'pending') and verify that the update actually affected a row before proceeding to send the message.

Comment on lines +72 to +77
if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
"send_status": string(enums.ChannelMessageOutboxStatusSending),
"updated_at": time.Now(),
}); err != nil {
return err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a race condition here. Since DispatchPendingOutbox is triggered asynchronously on every message enqueue, multiple goroutines can run concurrently. Both goroutines can fetch the same pending outbox items via ListPending before either has updated their status to sending. As a result, both will pass the SendStatus check and call client.SendTextMessage, causing duplicate messages to be sent to the customer.

To prevent this, you should use optimistic locking when updating the status to sending (e.g., UPDATE ... WHERE id = ? AND send_status = 'pending') and verify that the update actually affected a row before proceeding to send the message.

Comment on lines +73 to +78
if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
"send_status": string(enums.ChannelMessageOutboxStatusSending),
"updated_at": time.Now(),
}); err != nil {
return err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a race condition here. Since DispatchPendingOutbox is triggered asynchronously on every message enqueue, multiple goroutines can run concurrently. Both goroutines can fetch the same pending outbox items via ListPending before either has updated their status to sending. As a result, both will pass the SendStatus check and call client.PublishTextReply, causing duplicate messages to be sent to the customer.

To prevent this, you should use optimistic locking when updating the status to sending (e.g., UPDATE ... WHERE id = ? AND send_status = 'pending') and verify that the update actually affected a row before proceeding to send the message.

Comment on lines +1023 to +1027
: channelType === "viber"
? JSON.stringify({
authToken: form.viberAuthToken.trim(),
botName: form.viberBotName.trim(),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Viber channel configuration contains fields like avatarUrl and welcomeMessage on the backend, but these are completely missing from the frontend form state (EditForm), createEmptyForm, and buildPayload.

If a Viber channel has an avatarUrl or welcomeMessage configured (e.g., via API or default settings), editing and saving the channel in the UI will silently overwrite and delete these fields from the database because they are not included in the serialized JSON payload.

Please ensure that all configuration fields (including welcomeMessage for LINE and Threads) are either preserved during updates or added to the form UI so they are not lost.

- Degrade image/attachment messages to signed URL text on outbound (matches
  TikTok pattern); outbox enqueue now accepts image/attachment so agent media
  no longer silently drops for the new channels
- Threads webhook signature check is fail-closed once AppSecret is configured
- Use the stable @username as the Threads customer identity to prevent
  conversation fragmentation per reply (media id stays the dedupe key)
- Fall back to the channel name as the Viber sender name when botName is unset
- LINE: send the configured welcome message on follow events
- Round-trip all channel config fields through the edit dialog (welcome
  message, avatar URL, webhook secret) so saving no longer drops values set
  outside the form
- Show the auto-generated Threads webhook verify token read-only in the form
- Distinct icon for Threads channels
@JOY
JOY (JOY) merged commit 6a3b9c7 into dev Sep 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant