DEV Community

LunarDrift
LunarDrift

Posted on

A Community Reply Is Not Permission to DM: Build a Consent Gate for 1:1 Chat

A developer shares an opportunity in a community channel. Someone replies, “Can I ask you about this?” The natural product shortcut is to open a direct-message thread immediately.

That shortcut creates an uncomfortable ambiguity: being available in a community is not the same as agreeing to a private conversation.

The technical problem is not merely adding an “Accept” button. Acceptance can race with blocking, thread creation can fail, and a late callback can reopen a request that the recipient already declined. If an AI assistant drafts introductions, it adds another boundary: useful text generation must not become automated consent.

This tutorial builds a consent gate for moving from a Tencent RTC community interaction to 1:1 chat. The core is SDK-independent TypeScript, so its transition rules can be tested without a live messaging account.

Tencent RTC's Social Messaging solution covers community experiences alongside 1:1 and group chat. We will connect those capabilities through an application-owned consent workflow rather than assuming that membership in one context authorizes contact in another.

Define the user-visible contract

Our workflow has six promises:

  1. A requester can ask to start a DM, but cannot accept for the recipient.
  2. No composer is presented as ready until the recipient accepts and provisioning succeeds.
  3. A block or decline wins over a late provisioning result.
  4. Failed provisioning is visible and retryable; it is not silently reported as success.
  5. AI may draft a request, but a person must review and submit it.
  6. The original public context remains identifiable so the recipient knows why they were contacted.

This is an introduction-consent gate, not a complete abuse-prevention system. Once a DM is established, reporting, blocking, retention, and moderator escalation still need their own policies.

Model consent and provisioning separately

“Accepted” and “ready to message” are different facts. The recipient can grant consent while the application is still creating or locating the 1:1 conversation.

PENDING ──accept──> PROVISIONING ──success──> READY
   │                     │
   ├──decline──> DECLINED├──failure──> FAILED ──retry──> PROVISIONING
   ├──expire───> EXPIRED │
   └──block────> REVOKED <──block──────────────┘
Enter fullscreen mode Exit fullscreen mode

A late success event is valid only while the request remains PROVISIONING. If the recipient blocks the requester first, the result must be ignored.

Create a small project:

mkdir consent-dm-gate
cd consent-dm-gate
npm init -y
npm install --save-dev typescript tsx vitest @types/node
npx tsc --init
mkdir src
Enter fullscreen mode Exit fullscreen mode

Add the test command to package.json:

{
  "scripts": {
    "test": "vitest run"
  }
}
Enter fullscreen mode Exit fullscreen mode

Implement the policy as a pure transition function

Create src/consent.ts:

export type Status =
  | "PENDING"
  | "PROVISIONING"
  | "READY"
  | "DECLINED"
  | "REVOKED"
  | "EXPIRED"
  | "FAILED";

export interface DmRequest {
  id: string;
  requesterId: string;
  recipientId: string;
  contextMessageId: string;
  status: Status;
  expiresAt: number;
  version: number;
  conversationRef?: string;
  failureCode?: "TEMPORARY" | "UNKNOWN_OUTCOME";
}

type Command =
  | { type: "ACCEPT"; actorId: string; now: number; blocked: boolean }
  | { type: "DECLINE"; actorId: string; now: number }
  | { type: "BLOCK"; actorId: string; now: number }
  | { type: "EXPIRE"; now: number }
  | { type: "PROVISION_OK"; conversationRef: string; now: number }
  | {
      type: "PROVISION_FAILED";
      code: "TEMPORARY" | "UNKNOWN_OUTCOME";
      now: number;
    }
  | { type: "RETRY"; actorId: string; now: number; blocked: boolean };

export class TransitionError extends Error {}

function changed(request: DmRequest, patch: Partial<DmRequest>): DmRequest {
  return { ...request, ...patch, version: request.version + 1 };
}

function requireRecipient(request: DmRequest, actorId: string): void {
  if (actorId !== request.recipientId) {
    throw new TransitionError("Only the recipient may perform this action");
  }
}

function requireNotExpired(request: DmRequest, now: number): void {
  if (now >= request.expiresAt) {
    throw new TransitionError("The request has expired");
  }
}

export function transition(request: DmRequest, command: Command): DmRequest {
  switch (command.type) {
    case "ACCEPT": {
      requireRecipient(request, command.actorId);
      requireNotExpired(request, command.now);

      if (request.status !== "PENDING") {
        throw new TransitionError(`Cannot accept from ${request.status}`);
      }
      if (command.blocked) {
        return changed(request, { status: "REVOKED" });
      }

      return changed(request, {
        status: "PROVISIONING",
        failureCode: undefined
      });
    }

    case "DECLINE": {
      requireRecipient(request, command.actorId);
      if (request.status !== "PENDING") {
        throw new TransitionError(`Cannot decline from ${request.status}`);
      }
      return changed(request, { status: "DECLINED" });
    }

    case "BLOCK": {
      requireRecipient(request, command.actorId);
      if (["DECLINED", "EXPIRED", "REVOKED"].includes(request.status)) {
        return request;
      }
      return changed(request, {
        status: "REVOKED",
        conversationRef: undefined
      });
    }

    case "EXPIRE": {
      if (request.status !== "PENDING" || command.now < request.expiresAt) {
        return request;
      }
      return changed(request, { status: "EXPIRED" });
    }

    case "PROVISION_OK": {
      // A callback arriving after decline, block, or another transition is stale.
      if (request.status !== "PROVISIONING") return request;

      return changed(request, {
        status: "READY",
        conversationRef: command.conversationRef,
        failureCode: undefined
      });
    }

    case "PROVISION_FAILED": {
      if (request.status !== "PROVISIONING") return request;
      return changed(request, {
        status: "FAILED",
        failureCode: command.code
      });
    }

    case "RETRY": {
      requireRecipient(request, command.actorId);
      requireNotExpired(request, command.now);

      if (request.status !== "FAILED") {
        throw new TransitionError(`Cannot retry from ${request.status}`);
      }
      if (command.blocked) {
        return changed(request, { status: "REVOKED" });
      }

      return changed(request, {
        status: "PROVISIONING",
        failureCode: undefined
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The blocked value must come from an authoritative application lookup performed during the transaction. Do not trust a browser-supplied boolean.

Reproduce the dangerous races

Create src/consent.test.ts:

import { describe, expect, it } from "vitest";
import { DmRequest, transition } from "./consent";

const base = (): DmRequest => ({
  id: "req-17",
  requesterId: "alex",
  recipientId: "sam",
  contextMessageId: "community-message-42",
  status: "PENDING",
  expiresAt: 2_000,
  version: 1
});

describe("DM consent gate", () => {
  it("does not let the requester accept", () => {
    expect(() =>
      transition(base(), {
        type: "ACCEPT",
        actorId: "alex",
        now: 1_000,
        blocked: false
      })
    ).toThrow("Only the recipient");
  });

  it("does not turn consent into readiness before provisioning", () => {
    const accepted = transition(base(), {
      type: "ACCEPT",
      actorId: "sam",
      now: 1_000,
      blocked: false
    });

    expect(accepted.status).toBe("PROVISIONING");
    expect(accepted.conversationRef).toBeUndefined();
  });

  it("lets a block defeat a late success result", () => {
    const provisioning = transition(base(), {
      type: "ACCEPT",
      actorId: "sam",
      now: 1_000,
      blocked: false
    });
    const revoked = transition(provisioning, {
      type: "BLOCK",
      actorId: "sam",
      now: 1_010
    });
    const late = transition(revoked, {
      type: "PROVISION_OK",
      conversationRef: "conversation-9",
      now: 1_020
    });

    expect(late.status).toBe("REVOKED");
    expect(late.conversationRef).toBeUndefined();
  });

  it("exposes a retryable provisioning failure", () => {
    const provisioning = transition(base(), {
      type: "ACCEPT",
      actorId: "sam",
      now: 1_000,
      blocked: false
    });
    const failed = transition(provisioning, {
      type: "PROVISION_FAILED",
      code: "TEMPORARY",
      now: 1_010
    });

    expect(failed.status).toBe("FAILED");
    expect(failed.failureCode).toBe("TEMPORARY");
  });

  it("does not accept an expired request", () => {
    expect(() =>
      transition(base(), {
        type: "ACCEPT",
        actorId: "sam",
        now: 2_000,
        blocked: false
      })
    ).toThrow("expired");
  });
});
Enter fullscreen mode Exit fullscreen mode

Run the suite:

npm test
Enter fullscreen mode Exit fullscreen mode

The important test is not the happy path. It is the late provisioning result after a block. Without that guard, an asynchronous callback can reverse a human decision.

Persist transitions with optimistic concurrency

The pure function protects one in-memory aggregate. Production storage must also prevent two servers from updating the same request from the same version.

A minimal relational record could look like this:

CREATE TABLE dm_requests (
  id                 TEXT PRIMARY KEY,
  requester_id       TEXT NOT NULL,
  recipient_id       TEXT NOT NULL,
  context_message_id TEXT NOT NULL,
  status             TEXT NOT NULL,
  expires_at         TIMESTAMPTZ NOT NULL,
  version            INTEGER NOT NULL,
  conversation_ref   TEXT,
  failure_code       TEXT,
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  CHECK (requester_id <> recipient_id)
);

CREATE INDEX dm_requests_recipient_status
  ON dm_requests (recipient_id, status);
Enter fullscreen mode Exit fullscreen mode

Write an accepted transition using a compare-and-swap update:

UPDATE dm_requests
SET status = 'PROVISIONING',
    version = version + 1,
    updated_at = now()
WHERE id = $1
  AND recipient_id = $2
  AND status = 'PENDING'
  AND version = $3
  AND expires_at > now()
RETURNING *;
Enter fullscreen mode Exit fullscreen mode

If no row is returned, reload the request. It may have expired, been declined, been blocked in another transaction, or already been accepted.

Blocking and accepting must consult the same authoritative block relationship inside their transactions. Otherwise, this sequence remains possible:

  1. Server A checks that no block exists.
  2. Server B records a block.
  3. Server A commits acceptance based on stale information.

Use row locking, serializable transactions, or a versioned relationship record appropriate to your database. The exact mechanism matters less than making the race an explicit design decision.

Connect the gate to Tencent RTC Social Messaging

Keep product integration behind a narrow port:

export interface DirectMessageConnector {
  ensureConversation(input: {
    requestId: string;
    requesterId: string;
    recipientId: string;
  }): Promise<{ conversationRef: string }>;
}
Enter fullscreen mode Exit fullscreen mode

After the recipient accepts:

  1. Commit PROVISIONING first.
  2. Ask the connector to create or locate the relevant 1:1 conversation.
  3. Commit READY only if the stored request is still PROVISIONING at the expected version.
  4. If the operation definitely failed, record FAILED/TEMPORARY.
  5. If the call timed out after being sent, record FAILED/UNKNOWN_OUTCOME and reconcile before creating anything again.

The adapter's SDK calls depend on the Tencent RTC Chat SDK, platform, and version you selected, so this tutorial deliberately does not invent method names. Use the current official integration documentation for that connector. The state machine remains the application contract around it.

An UNKNOWN_OUTCOME deserves special treatment. A timeout does not prove that the remote operation failed. A blind retry can create duplicate local mappings or confusing UI. First query or reconcile the conversation state using the supported facilities of your chosen integration, then finalize the original request.

Also distinguish a UI gate from an authorization boundary. Hiding a composer prevents accidental initiation, but it does not by itself stop a modified client. If your architecture requires strict enforcement, ensure that the path capable of initiating contact cannot bypass the server-side consent decision.

Render state instead of optimistic fiction

The recipient and requester should see different controls:

State Recipient UI Requester UI
PENDING Accept, decline, block Request sent; cancel if your policy supports it
PROVISIONING “Opening conversation…” “Waiting for conversation…”
READY Open DM Open DM
FAILED Retry or cancel “Could not open the conversation”
DECLINED Dismissed Declined, or a privacy-preserving generic result
EXPIRED Request expired Request expired
REVOKED Block controls No contact control

Whether the requester should be told “blocked” is a safety and privacy decision, not a database-display decision. A generic “This conversation cannot be opened” result often reveals less about the recipient's moderation choices.

Translation belongs after the consent decision

A multilingual community may need the recipient to understand why someone is asking to connect. Tencent RTC documents on-demand text-message translation through TUIChat in its message translation guide, including supported content, languages, and edition constraints that should be checked for the chosen deployment.

Treat translation as a reading aid around the request:

  • Retain and display the original introduction.
  • Label translated text as translated.
  • Do not convert translated text into an automatic accept, decline, or moderation command.
  • If translation is unavailable, leave the original visible and offer a retry rather than replacing it with guessed text.

Consent still comes from the recipient's explicit action, not from a translation result.

Where an AI assistant helps—and where it does not

An assistant can make a blank introduction box less awkward. For example, it can draft:

“I’m contacting you about the backend internship link you shared in the community thread. May I ask how you evaluated the role?”

That capability is useful but narrow. The model has not demonstrated that the recipient welcomes a DM, that the opportunity is still open, or that the drafted interpretation is correct.

A controlled implementation should therefore:

  1. Pass only the public context the requester deliberately selects.
  2. Generate a draft, not a sent message.
  3. Show the source community message beside the draft.
  4. Require the requester to edit or confirm it.
  5. Run normal moderation checks on the submitted text.
  6. Route ambiguous or serious safety reports to people rather than asking the model for a final judgment.

Do not silently add private profile data, previous DMs, or unrelated community history to the prompt. More context is not automatically better context; it can be an unexpected change in audience.

The human decision underneath the AI discussion is straightforward: who gets to decide that a public interaction becomes a private relationship? Text generation cannot make that decision for either participant.

Failure drills before release

Run these scenarios in staging, not only in unit tests:

  • Accept and block from two browser tabs at nearly the same time.
  • Expire a request while its acceptance screen is still open.
  • Disconnect the network after acceptance but before provisioning returns.
  • Return success after the request has been revoked.
  • Deliver the same success callback twice.
  • Make the connector time out with an unknown remote outcome.
  • Remove or hide the original community message after the request was created.
  • Submit an introduction that moderation holds for human review.
  • Make translation unavailable while preserving the original text.
  • Attempt to initiate contact from a modified client without a READY request.

For every drill, verify both persisted state and visible UI. A correct database row with a misleading “Message now” button is still a product failure.

Release checklist

  • [ ] Only the intended recipient can accept or decline.
  • [ ] Expiration is checked on the server.
  • [ ] Acceptance and blocking have a defined concurrency strategy.
  • [ ] PROVISIONING is visible and distinct from READY.
  • [ ] Late callbacks cannot reverse decline, expiry, or revocation.
  • [ ] Unknown outcomes are reconciled rather than blindly retried.
  • [ ] The original community context is identifiable.
  • [ ] Translations are labeled and do not replace the original.
  • [ ] AI output remains an editable draft.
  • [ ] Moderation includes a visible path to human review.
  • [ ] Modified clients cannot bypass the intended initiation policy.
  • [ ] Logs record request IDs and state transitions without unnecessarily copying message bodies.

A design question for your community

There is no universal expiry period or disclosure policy. A mentorship community, an event channel, and a peer-support space may need different defaults.

Before choosing the UI, decide these three things with moderators and members:

  • Does consent authorize one opening message or an ongoing conversation?
  • What context must a requester provide before the recipient decides?
  • Which outcomes should remain private from the requester?

Those answers determine the state machine more reliably than starting with a generic DM screen.

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

Top comments (0)