DEV Community

LunarDrift
LunarDrift

Posted on

Build a Claimable Community DM Queue That Cannot Silently Drop Requests

A community adds a “Talk to a coordinator” button. The demo is convincing: click it, open a direct message, reply.

The operational version is harder.

What happens when two coordinators claim the same request? What if a worker creates the conversation but crashes before saving its ID? Should an expired claim be retried automatically? Who notices when translation fails?

This is partly a technical problem and partly a role problem. A coordinator should not have to remain perpetually online to prove that the community is cared for. The system should make ownership, waiting, and failure visible.

In this tutorial, we will build the application-owned state machine behind a claimable community DM queue. Tencent RTC provides the social messaging layer; our application remains responsible for assignment, retries, audit state, and escalation.

Tencent RTC's Social Messaging solution covers scenarios including 1:1 chat, group discussions, communities, and rich media. We will use the 1:1 scenario here without assuming that the messaging SDK also owns our support workflow.

Decide when a DM is actually appropriate

Moving every community interaction into private chat creates an unsearchable support burden. Use a small routing policy before writing code:

Request Destination Reason
General “how do I?” question Public discussion The answer can help other members
Account-specific information DM queue The member may need to share private context
Conduct or safety report Dedicated moderation route It needs restricted access and a different response policy
Informal introduction Public thread, with optional member-initiated DM A private conversation should not be imposed
Product bug with reproducible details Public issue or support route It needs durable technical evidence

The important constraint is that the member initiates the DM request. A public introduction is not blanket consent for unsolicited private contact.

Our example starts after that routing decision:

Member requests private help
        ↓
Request enters queue
        ↓
One coordinator claims it
        ↓
Worker attempts to open/send the first DM
        ↓
Conversation becomes active or enters recovery
        ↓
Coordinator resolves the request
Enter fullscreen mode Exit fullscreen mode

The dangerous interval

The most important interval is between these two facts:

  1. The messaging operation may have succeeded.
  2. The application has not yet recorded the resulting conversation.

If a process crashes there, blindly retrying can create duplicate messages. Blindly abandoning the operation can drop the request.

We will represent that uncertainty as a real state named needs_reconcile. It is not an exception hidden in a log.

Create the project

Use Node.js 20 or later:

mkdir community-dm-queue
cd community-dm-queue
npm init -y
npm install --save-dev typescript tsx @types/node
mkdir -p src test
Enter fullscreen mode Exit fullscreen mode

Update package.json:

{
  "type": "module",
  "scripts": {
    "test": "tsx --test test/*.test.ts"
  },
  "devDependencies": {
    "@types/node": "latest",
    "tsx": "latest",
    "typescript": "latest"
  }
}
Enter fullscreen mode Exit fullscreen mode

Model uncertainty instead of hiding it

Create src/domain.ts:

export type RequestState =
  | { kind: "queued"; attempts: number }
  | {
      kind: "claimed";
      attempts: number;
      ownerId: string;
      leaseUntil: number;
    }
  | {
      kind: "opening";
      attempts: number;
      ownerId: string;
      deliveryKey: string;
      leaseUntil: number;
    }
  | {
      kind: "needs_reconcile";
      attempts: number;
      ownerId: string;
      deliveryKey: string;
      reason: string;
    }
  | {
      kind: "active";
      attempts: number;
      ownerId: string;
      conversationId: string;
    }
  | {
      kind: "resolved";
      ownerId: string;
      conversationId: string;
      resolution: string;
    };

export interface DmRequest {
  id: string;
  memberId: string;
  topic: string;
  sourceLocale?: string;
  revision: number;
  state: RequestState;
}

export type Event =
  | { type: "claim"; ownerId: string; leaseUntil: number }
  | { type: "start_open"; ownerId: string; deliveryKey: string; leaseUntil: number }
  | { type: "dm_opened"; deliveryKey: string; conversationId: string }
  | { type: "delivery_failed"; deliveryKey: string; definitive: boolean; reason: string }
  | { type: "lease_elapsed"; now: number }
  | { type: "reconciled_absent"; deliveryKey: string }
  | { type: "resolve"; ownerId: string; resolution: string };

export function transition(request: DmRequest, event: Event): DmRequest {
  const state = request.state;
  let next: RequestState;

  switch (event.type) {
    case "claim": {
      if (state.kind !== "queued") throw new Error("request_not_available");

      next = {
        kind: "claimed",
        attempts: state.attempts,
        ownerId: event.ownerId,
        leaseUntil: event.leaseUntil
      };
      break;
    }

    case "start_open": {
      if (state.kind !== "claimed" || state.ownerId !== event.ownerId) {
        throw new Error("claim_not_owned");
      }

      next = {
        kind: "opening",
        attempts: state.attempts + 1,
        ownerId: state.ownerId,
        deliveryKey: event.deliveryKey,
        leaseUntil: event.leaseUntil
      };
      break;
    }

    case "dm_opened": {
      if (
        (state.kind !== "opening" && state.kind !== "needs_reconcile") ||
        state.deliveryKey !== event.deliveryKey
      ) {
        throw new Error("stale_delivery_result");
      }

      next = {
        kind: "active",
        attempts: state.attempts,
        ownerId: state.ownerId,
        conversationId: event.conversationId
      };
      break;
    }

    case "delivery_failed": {
      if (state.kind !== "opening" || state.deliveryKey !== event.deliveryKey) {
        throw new Error("stale_delivery_failure");
      }

      next = event.definitive
        ? { kind: "queued", attempts: state.attempts }
        : {
            kind: "needs_reconcile",
            attempts: state.attempts,
            ownerId: state.ownerId,
            deliveryKey: state.deliveryKey,
            reason: event.reason
          };
      break;
    }

    case "lease_elapsed": {
      if (state.kind === "claimed" && event.now >= state.leaseUntil) {
        next = { kind: "queued", attempts: state.attempts };
        break;
      }

      if (state.kind === "opening" && event.now >= state.leaseUntil) {
        next = {
          kind: "needs_reconcile",
          attempts: state.attempts,
          ownerId: state.ownerId,
          deliveryKey: state.deliveryKey,
          reason: "delivery_outcome_unknown"
        };
        break;
      }

      throw new Error("lease_not_elapsed");
    }

    case "reconciled_absent": {
      if (
        state.kind !== "needs_reconcile" ||
        state.deliveryKey !== event.deliveryKey
      ) {
        throw new Error("reconciliation_mismatch");
      }

      next = { kind: "queued", attempts: state.attempts };
      break;
    }

    case "resolve": {
      if (state.kind !== "active" || state.ownerId !== event.ownerId) {
        throw new Error("active_request_not_owned");
      }

      next = {
        kind: "resolved",
        ownerId: state.ownerId,
        conversationId: state.conversationId,
        resolution: event.resolution
      };
      break;
    }
  }

  return {
    ...request,
    revision: request.revision + 1,
    state: next
  };
}
Enter fullscreen mode Exit fullscreen mode

A claim can safely expire because no external effect has started. An opening operation cannot safely return directly to the queue because its external effect may already have happened.

That distinction prevents “timeout” from becoming another word for “duplicate it.”

Make claims atomic

The pure reducer rejects a second claim, but two application instances could both read the same queued record before either writes its update. Persistence therefore needs compare-and-swap semantics.

A minimal repository boundary looks like this:

export interface RequestRepository {
  get(id: string): Promise<DmRequest | undefined>;

  saveIfRevisionMatches(
    request: DmRequest,
    expectedRevision: number
  ): Promise<boolean>;
}

export async function applyEvent(
  repository: RequestRepository,
  requestId: string,
  event: Event
): Promise<DmRequest> {
  const current = await repository.get(requestId);
  if (!current) throw new Error("request_not_found");

  const updated = transition(current, event);
  const saved = await repository.saveIfRevisionMatches(
    updated,
    current.revision
  );

  if (!saved) throw new Error("concurrent_update");
  return updated;
}
Enter fullscreen mode Exit fullscreen mode

In SQL, the essential write condition is:

UPDATE dm_requests
SET state_json = ?, revision = revision + 1
WHERE id = ? AND revision = ?;
Enter fullscreen mode Exit fullscreen mode

Treat an affected-row count of zero as a conflict. Reload the request rather than pretending that the claim succeeded.

Keep Tencent RTC behind an application port

Do not scatter messaging SDK calls throughout the queue logic. Define an application-owned boundary:

export interface MessagingPort {
  deliverFirstMessage(input: {
    deliveryKey: string;
    memberId: string;
    coordinatorId: string;
    text: string;
  }): Promise<{ conversationId: string }>;
}
Enter fullscreen mode Exit fullscreen mode

MessagingPort and deliverFirstMessage are names in this tutorial, not Tencent RTC API names. Implement the adapter with the Tencent RTC social messaging integration appropriate to your client or server architecture.

The adapter also needs its own delivery ledger:

CREATE TABLE delivery_ledger (
  delivery_key TEXT PRIMARY KEY,
  status TEXT NOT NULL,
  conversation_id TEXT,
  updated_at TEXT NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Before delivery, insert or look up the stable deliveryKey. After success, persist the resulting conversation mapping. If the worker crashes after messaging succeeds, reconciliation can inspect this ledger:

  • Mapping exists: emit dm_opened.
  • A definitive failed attempt exists: emit reconciled_absent.
  • The result is still uncertain: remain in needs_reconcile and alert an operator.

Do not generate a new delivery key for every retry. That would defeat the purpose of the ledger.

What the member should see

Internal precision should produce simple user-facing states:

Internal state Member-facing wording
queued “Your private conversation request is waiting for a coordinator.”
claimed “A coordinator is reviewing your request.”
opening “We’re opening the conversation.”
needs_reconcile “We’re checking whether the conversation opened. Please don’t submit again.”
active Link or navigate to the DM
resolved “This request has been closed.”

That needs_reconcile wording matters. Telling someone to retry while your system is uncertain encourages duplicate requests and makes the member responsible for your infrastructure ambiguity.

Add translation as a view, not a rewrite

A multilingual community may want on-demand translation inside the conversation. Tencent RTC documents this through TUIChat message translation.

Before promising the feature, verify the documented supported content types, languages, and edition requirements against your deployment. Do not treat translation as universally available for every message.

Keep these records separate:

interface StoredMessage {
  messageId: string;
  conversationId: string;
  authorId: string;
  originalText: string;
  originalLocale?: string;
}

interface TranslationView {
  messageId: string;
  targetLocale: string;
  status: "requested" | "ready" | "failed";
  translatedText?: string;
  failureCode?: string;
}
Enter fullscreen mode Exit fullscreen mode

The original message remains the durable community record. A translation failure should affect only TranslationView; it must not close, reassign, or otherwise mutate the DM request.

The UI should also label translated text and let the reader return to the original. Translation assists communication, but it does not remove the human need to clarify ambiguous wording.

Verify the race conditions locally

Create test/domain.test.ts:

import assert from "node:assert/strict";
import test from "node:test";
import { DmRequest, transition } from "../src/domain.js";

function queued(): DmRequest {
  return {
    id: "req-1",
    memberId: "member-7",
    topic: "account-specific question",
    revision: 0,
    state: { kind: "queued", attempts: 0 }
  };
}

test("a second coordinator cannot claim an owned request", () => {
  const claimed = transition(queued(), {
    type: "claim",
    ownerId: "coordinator-a",
    leaseUntil: 1_000
  });

  assert.throws(
    () => transition(claimed, {
      type: "claim",
      ownerId: "coordinator-b",
      leaseUntil: 2_000
    }),
    /request_not_available/
  );
});

test("an opening timeout requires reconciliation, not blind retry", () => {
  const claimed = transition(queued(), {
    type: "claim",
    ownerId: "coordinator-a",
    leaseUntil: 1_000
  });

  const opening = transition(claimed, {
    type: "start_open",
    ownerId: "coordinator-a",
    deliveryKey: "delivery-123",
    leaseUntil: 2_000
  });

  const uncertain = transition(opening, {
    type: "lease_elapsed",
    now: 2_001
  });

  assert.equal(uncertain.state.kind, "needs_reconcile");
});

test("a matching late result can recover an uncertain delivery", () => {
  const request: DmRequest = {
    ...queued(),
    state: {
      kind: "needs_reconcile",
      attempts: 1,
      ownerId: "coordinator-a",
      deliveryKey: "delivery-123",
      reason: "delivery_outcome_unknown"
    }
  };

  const active = transition(request, {
    type: "dm_opened",
    deliveryKey: "delivery-123",
    conversationId: "conversation-9"
  });

  assert.equal(active.state.kind, "active");
});

test("a stale callback cannot activate the wrong request attempt", () => {
  const request: DmRequest = {
    ...queued(),
    state: {
      kind: "needs_reconcile",
      attempts: 2,
      ownerId: "coordinator-b",
      deliveryKey: "delivery-new",
      reason: "delivery_outcome_unknown"
    }
  };

  assert.throws(
    () => transition(request, {
      type: "dm_opened",
      deliveryKey: "delivery-old",
      conversationId: "conversation-old"
    }),
    /stale_delivery_result/
  );
});
Enter fullscreen mode Exit fullscreen mode

Run the suite:

npm test
Enter fullscreen mode Exit fullscreen mode

Failure drills for staging

Unit tests validate domain rules. Staging drills validate whether the surrounding system respects them.

The coordinator closes the browser after claiming

Let the claim lease expire. Because delivery never began, the request can return to queued.

Verify that the previous owner cannot start delivery using the expired revision.

The process exits after sending the first message

Force termination between messaging success and the application callback.

Expected result: the request enters needs_reconcile; the delivery ledger later recovers the conversation mapping. It must not immediately send a second greeting.

Two workers receive the same queue job

Both can read the job, but only one may move the persisted request from claimed to opening. The other should observe a revision conflict and stop.

A late callback arrives after another attempt begins

The callback's deliveryKey must match the current attempt. Reject mismatches rather than attaching an old conversation to a new owner.

Translation is unavailable

Display the original message and a retryable translation error. Do not mark the community request as failed, and do not replace the original text with an empty translation.

The topic is actually a safety report

Do not allow the general coordinator queue to become an accidental moderation system. Redirect the request to the restricted route and explain that transition to the member.

Release checklist

Before enabling the button for a community, verify:

  • [ ] The member explicitly initiates the private request.
  • [ ] Public questions are not unnecessarily diverted into DMs.
  • [ ] Claims use an atomic revision or equivalent database condition.
  • [ ] Claim leases and delivery leases have different recovery behavior.
  • [ ] Every delivery attempt has a stable correlation key.
  • [ ] Unknown delivery outcomes stop in needs_reconcile.
  • [ ] Stale callbacks cannot activate a newer attempt.
  • [ ] Members can see whether they are queued, assigned, or in recovery.
  • [ ] Translation preserves the original message and fails independently.
  • [ ] Moderation and urgent safety reports use a separate restricted workflow.
  • [ ] Coordinators have a documented way to release, resolve, and escalate requests.
  • [ ] Logs avoid unnecessary private message content.

The broader community lesson

A reliable DM queue does not make community work less human. It stops care from depending on memory, browser tabs, and whoever happens to be online.

The useful boundary is straightforward: Tencent RTC handles the social messaging experience, while your application owns consent, assignment, uncertainty, and recovery. Once those responsibilities are explicit, a private conversation can feel personal without becoming operationally invisible.

Disclosure: I have a content relationship with Tencent RTC, and I used the official Tencent RTC documentation linked above as the implementation reference for this article.

How would your community distinguish a question that deserves a public answer from one that should enter a private, claimable queue?

Top comments (0)