跳到正文
DEV Community· BotSailor·· 2 小時前AI 評分50

聊天機器人跨渠道訊息路由的構建經驗與教訓

What We Learned Building Cross-Channel Message Routing

AI 導讀

團隊分享構建聊天機器人跨渠道訊息路由的經驗,提出先統一各渠道的訊息格式,再以適配器隔離渠道差異,並按規則把訊息送往流程、AI 智能體或人工處理。新增渠道只需編寫適配器,毋須重寫聊天機器人;針對 webhook 重試和網絡故障,可按渠道及 externalId 去重,以減少重複回覆。

正文

What We Learned Building Cross-Channel Message Routing for Chatbots

Sooner or later, every chatbot project hits the same wall: customers don't stay on one channel. A user asks a question on Instagram, follows up on WhatsApp, and expects the Telegram bot to know what happened. If each channel runs its own isolated logic, you end up with duplicated flows, lost context, and replies that arrive twice or not at all.

This is a behind-the-scenes post about how we approached cross-channel message routing for chatbots, the mistakes we made, and the patterns that held up. By the end you'll know how to design a normalized message format, build channel adapters, route messages reliably, and avoid the failure modes that cost us the most time.

Table of Contents

Prerequisites

You don't need a full production stack to follow along. It helps to have:

  • [ ] Basic familiarity with webhooks and REST APIs
  • [ ] Node.js 18+ and TypeScript (the examples use them, but the ideas are language-agnostic)
  • [ ] An account or sandbox for at least two messaging channels (e.g., WhatsApp Business API, Telegram Bot API, Instagram Messaging)
  • [ ] A queue or job system (Redis, RabbitMQ, SQS, or similar)

What is cross-channel message routing?

Cross-channel message routing is the layer that sits between your messaging channels and your bot logic. It receives inbound messages from any channel, decides where they should go (a flow, an AI agent, or a human), and delivers the reply on the right channel in the right format.

Why it matters: without a routing layer, every new channel multiplies your logic. With one, adding a channel means writing an adapter, not rewriting your bot.

Here's the high-level shape:

[WhatsApp] [Telegram] [Instagram]
     \         |         /
      \        |        /
     [ Channel Adapters ]
              |
     [ Normalized Message ]
              |
        [ Routing Engine ]
         /     |      \
   [Flow]  [AI Agent]  [Human]
              |
     [ Outbound Dispatcher ]

Lesson 1: Normalize messages before you route them

Our first version passed raw webhook payloads around. It worked until we added the second channel and every if (channel === "...") branch started leaking into business logic.

The fix was a single internal message schema that every channel converts into:

type Channel = "whatsapp" | "telegram" | "instagram";

interface NormalizedMessage {
  id: string;              // our internal ID
  externalId: string;      // the channel's message ID
  channel: Channel;
  contactId: string;       // stable ID across channels (when known)
  channelUserId: string;   // the user's ID on that channel
  type: "text" | "image" | "audio" | "button_reply" | "other";
  text?: string;
  mediaUrl?: string;
  receivedAt: string;      // ISO timestamp
  raw: unknown;            // keep the original payload for debugging
}

Two small decisions paid off immediately:

  • Keep raw. When a channel changes its payload format, you'll want the original to debug against.
  • Separate contactId from channelUserId. This is what later lets you recognize the same person on different channels.

Lesson 2: Keep channel quirks inside adapters

Every channel has its own rules: message length limits, button formats, media handling, and messaging windows. We moved all of that into adapters with the same interface:

interface ChannelAdapter {
  channel: Channel;
  parseInbound(payload: unknown): NormalizedMessage;
  send(to: string, message: OutboundMessage): Promise<SendResult>;
}

class TelegramAdapter implements ChannelAdapter {
  channel: Channel = "telegram";

  parseInbound(payload: any): NormalizedMessage {
    const msg = payload.message;
    return {
      id: crypto.randomUUID(),
      externalId: String(msg.message_id),
      channel: "telegram",
      contactId: "", // resolved later by the contact service
      channelUserId: String(msg.from.id),
      type: msg.text ? "text" : "other",
      text: msg.text,
      receivedAt: new Date(msg.date * 1000).toISOString(),
      raw: payload,
    };
  }

  async send(to: string, message: OutboundMessage) {
    // Telegram-specific formatting and API call live here
    return { ok: true };
  }
}

Rule of thumb: if a line of code mentions a specific channel, it should live in an adapter.

A useful side effect is that you can test routing logic with a fake adapter and no network calls.

Lesson 3: Build the router around rules, not channels

Our early router asked, "Which channel did this come from?" The better question turned out to be, "What does this conversation need?"

We switched to declarative rules evaluated in priority order:

interface RoutingRule {
  name: string;
  priority: number;
  when: (msg: NormalizedMessage, ctx: ConversationContext) => boolean;
  to: "flow" | "ai_agent" | "human";
  target?: string;
}

const rules: RoutingRule[] = [
  {
    name: "human-takeover-active",
    priority: 1,
    when: (_m, ctx) => ctx.humanAssigned,
    to: "human",
  },
  {
    name: "keyword-support",
    priority: 2,
    when: (m) => /refund|complaint|cancel/i.test(m.text ?? ""),
    to: "human",
  },
  {
    name: "default-ai",
    priority: 99,
    when: () => true,
    to: "ai_agent",
    target: "general-assistant",
  },
];

function route(msg: NormalizedMessage, ctx: ConversationContext) {
  return [...rules]
    .sort((a, b) => a.priority - b.priority)
    .find((r) => r.when(msg, ctx))!;
}

Because rules don't care about the channel, the same logic works on WhatsApp, Telegram, and Instagram. You can still add channel-aware rules when you need them, such as a rule for a channel's messaging window.

Lesson 4: Make delivery idempotent

Webhooks get retried. Networks fail. If you don't plan for this, users get duplicate replies. Two protections helped us the most:

  1. Deduplicate inbound messages using channel + externalId.
  2. Use idempotency keys on outbound sends so a retried job doesn't send twice.
async function handleInbound(msg: NormalizedMessage) {
  const key = `inbound:${msg.channel}:${msg.externalId}`;
  const isNew = await redis.set(key, "1", "EX", 86400, "NX");
  if (!isNew) return; // duplicate webhook, ignore

  await queue.add("route-message", msg, {
    jobId: `route:${msg.channel}:${msg.externalId}`,
  });
}

Acknowledge the webhook fast and do the real work in a queue. Channels penalize slow responses, and long AI calls shouldn't block your HTTP handler.

Lesson 5: Plan for fallbacks and human handoff

Things that will go wrong: a channel API times out, a message is rejected, or the AI isn't confident. Decide the behavior in advance:

  • Retry with backoff for transient errors (timeouts, 5xx).
  • Don't retry permanent errors (blocked user, invalid recipient). Mark them and move on.
  • Hand off to a human when confidence is low or the user asks for one, and make the router respect that state (see the human-takeover-active rule above).
  • Log every routing decision with the rule name, so you can answer "why did the bot do that?" later.
async function dispatch(adapter: ChannelAdapter, to: string, msg: OutboundMessage) {
  try {
    return await adapter.send(to, msg);
  } catch (err) {
    if (isTransient(err)) throw err;   // let the queue retry
    await markUndeliverable(to, err);  // permanent failure
  }
}

Mistakes we'd avoid next time

  • Routing on raw payloads. Normalize first, always.
  • Treating identity as an afterthought. Decide early how you'll link a user across channels.
  • Skipping decision logs. Debugging a routing bug without them is guesswork.
  • Assuming channels behave alike. Message windows, formats, and rate limits differ more than the docs suggest.

Replace this section with 1-2 real incidents from your build (a bug, a surprising channel behavior, a number you measured) before publishing.

Conclusion

The main takeaways from building cross-channel message routing for chatbots:

  • Normalize every inbound message into one schema.
  • Isolate channel-specific behavior inside adapters.
  • Route on conversation needs using ordered rules, not on channel names.
  • Make both inbound and outbound delivery idempotent.
  • Design fallbacks and human handoff from day one.

These patterns take time to get right, and many teams would rather spend that time on the customer experience than on plumbing. That's the problem platforms like Botsailor aim to take off your plate, with omnichannel automation across WhatsApp, Telegram, Instagram, and more, so you can focus on conversations instead of webhooks. If you're building your own, we hope these lessons save you a few late nights.

How do you handle routing across channels? Share your approach or your worst webhook horror story in the comments.

來源:DEV Community · dev.to