Networking Needs a Protocol: Build a Consent-First Community Introduction Flow
Networking advice often assumes the difficult part is finding the right words. In a community product, the harder problem is usually uncertainty: Was the invitation delivered? Does silence mean no, not yet, or a technical failure? Can the recipient decline without starting an awkward conversation? Should a retry create another notification? That ambiguity can feel like a confidence problem, but part of it is a protocol problem. A better interface cannot remove the vulnerability of meeting someone new. It can make intent, consent, and failure legible. In this tutorial, we will build a small introduction handshake for a social community: One member requests a private introduction. The system attempts to deliver the invitation. The recipient may accept or decline. A direct conversation becomes available only after acceptance. Unknown delivery, duplicate callbacks, unauthorized responses, and expiration remain explicit. Tencent RTC's Social Messaging solution covers experiences including one-to-one chat, group discussion, large communities, and interest-based social interaction. We will keep the community-specific policy in our application and place the messaging integration behind an adapter rather than inventing SDK methods. The product decision before the code There are at least three ways to implement introductions: Design Advantage Cost Immediately open a direct chat Fewest steps The recipient has no meaningful acceptance boundary Ask members to coordinate in a public channel Transparent and simple Public social pressure; conversations can become noisy Send a private, expiring invitation Consent and intent are explicit Requires more state and recovery behavior We will use the third design. This is not a claim that every community needs formal introductions. Use it when unsolicited direct messages are a real product concern or when members need a low-pressure way to express professional interest. For a small, trusted team, the extra state may be unnecessary. Define the states users can actually understand Our invitation can occupy one of these states: sending ├── delivered ──> awaiting_response ──> accepted │ └──> declined │ └──> expired ├── failed └── delivery_unknown ──> sending (retry same logical request) delivery_unknown is intentionally different from failed. A timeout does not prove that a message was not delivered. If we create a fresh invitation after every timeout, the recipient may receive duplicates. Instead, retries retain the same request ID and delivery key. Acceptance is also not inferred from activity. Reading an invitation, viewing a profile, or continuing to participate in the community is not consent to a private conversation. Create the TypeScript project This example uses Node.js, TypeScript, and the built-in assertion library. mkdir community-introductions cd community-introductions npm init -y npm install --save-dev typescript tsx @types/node mkdir src Add these scripts to package.json: { "scripts": { "check": "tsc --noEmit", "test": "tsx src/domain.test.ts" } } Create tsconfig.json: { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true }, "include": ["src/**/*.ts"] } Model delivery separately from the social decision Create src/domain.ts: export type IntroStatus = | "sending" | "awaiting_response" | "delivery_unknown" | "failed" | "accepted" | "declined" | "expired"; export type Introduction = { requestId: string; deliveryKey: string; communityId: string; requesterId: string; recipientId: string; note: string; status: IntroStatus; createdAt: number; expiresAt: number; deliveredAt?: number; respondedAt?: number; }; export type Effect = | { type: "send_invitation"; requestId: string; deliveryKey: string; recipientId: string; } | { type: "notify_requester"; requestId: string; outcome: "accepted" | "declined"; } | { type: "enable_direct_conversation"; requestId: string; memberIds: readonly [string, string]; }; export type Transition = { introduction: Introduction; effects: readonly Effect[]; }; export function createIntroduction(input: { requestId: string; communityId: string; requesterId: string; recipientId: string; note: string; now: number; ttlMs: number; }): Transition { if (input.requesterId === input.recipientId) { throw new Error("Cannot request an introduction to yourself"); } const note = input.note.trim(); if (note.length === 0) { throw new Error("An introduction needs a visible reason"); } if (input.ttlMs = current.expiresAt) { return expire(current, now); } return { introduction: { ...current, status: "awaiting_response", deliveredAt: now }, effects: [] }; } export function markDeliveryUnknown( current: Introduction ): Transition { // A late timeout must not overwrite a success callback. if (current.status !== "sending") { return unchanged(current); } return { introduction: { ...current, status: "delivery_unknown" }, effects: [] }; } export function markDeliveryFailed(current: Introduction): Transition { if (current.status !== "sending") { return unchanged(current); } return { introduction: { ...current, status: "failed" }, effects: [] }; } export function retryDelivery( current: Introduction, now: number ): Transition { if (current.status !== "delivery_unknown" && current.status !== "failed") { throw new Error(`Cannot retry from ${current.status}`); } if (now >= current.expiresAt) { return expire(current, now); } const introduction: Introduction = { ...current, status: "sending" }; return { introduction, // Same requestId and deliveryKey: this is not a new social request. effects: [toSendEffect(introduction)] }; } export function respond( current: Introduction, actorId: string, decision: "accepted" | "declined", now: number ): Transition { if (actorId !== current.recipientId) { throw new Error("Only the recipient may answer this invitation"); } if (now >= current.expiresAt && current.status === "awaiting_response") { return expire(current, now); } // Repeating the same response is idempotent. if (current.status === decision) { return unchanged(current); } if (current.status === "accepted" || current.status === "declined") { throw new Error("The invitation already has a different final decision"); } if (current.status !== "awaiting_response") { throw new Error(`Cannot respond from ${current.status}`); } const introduction: Introduction = { ...current, status: decision, respondedAt: now }; const effects: Effect[] = [ { type: "notify_requester", requestId: current.requestId, outcome: decision } ]; if (decision === "accepted") { effects.push({ type: "enable_direct_conversation", requestId: current.requestId, memberIds: [current.requesterId, current.recipientId] }); } return { introduction, effects }; } export function expire( current: Introduction, now: number ): Transition { if (now < current.expiresAt) { return unchanged(current); } if ( current.status === "accepted" || current.status === "declined" || current.status === "expired" ) { return unchanged(current); } return { introduction: { ...current, status: "expired" }, effects: [] }; } function toSendEffect(introduction: Introduction): Effect { return { type: "send_invitation", requestId: introduction.requestId, deliveryKey: introduction.deliveryKey, recipientId: introduction.recipientId }; } function unchanged(introduction: Introduction): Transition { return { introduction, effects: [] }; } There are two important boundaries here: Delivery state answers whether the application knows what happened to its send attempt. Decision state answers whether the recipient consented to the introduction. A successful send is not an acceptance. A send timeout is not a rejection. Execute effects through a narrow messaging port The domain code deliberately does not name a Tencent RTC SDK method. Exact integration calls depend on the platform and SDK selected for your application, and unsupported API names should not be guessed. Instead, define an application port in src/effects.ts: import type { Effect } from "./domain.js"; export interface CommunityMessagingPort { sendIntroduction(input: { requestId: string; deliveryKey: string; recipientId: string; }): Promise; notifyRequester(input: { requestId: string; outcome: "accepted" | "declined"; }): Promise; enableDirectConversation(input: { requestId: string; memberIds: readonly [string, string]; }): Promise; } export async function executeEffect( effect: Effect, messaging: CommunityMessagingPort ): Promise { switch (effect.type) { case "send_invitation": return messaging.sendIntroduction(effect); case "notify_requester": await messaging.notifyRequester(effect); return "done"; case "enable_direct_conversation": await messaging.enableDirectConversation(effect); return "done"; } } Your production adapter should map this port to the supported messaging operations documented for your chosen Tencent RTC integration. The application database should enforce uniqueness for requestId, while an invitation rendered from incoming messages should also be deduplicated by that logical identifier. Do not rely solely on a provider accepting deliveryKey. If transport-level idempotency is unavailable, receiver-side deduplication still prevents two visible cards from becoming two independent decisions. A useful persistence constraint is: CREATE TABLE introduction_requests ( request_id TEXT PRIMARY KEY, delivery_key TEXT NOT NULL UNIQUE, community_id TEXT NOT NULL, requester_id TEXT NOT NULL, recipient_id TEXT NOT NULL, note TEXT NOT NULL, status TEXT NOT NULL, created_at BIGINT NOT NULL, expires_at BIGINT NOT NULL, delivered_at BIGINT, responded_at BIGINT ); The server—not the client—must authorize respond() using the authenticated member ID. Hiding an Accept button in the UI is not authorization. Reproduce the dangerous paths Create src/domain.test.ts: import assert from "node:assert/strict"; import { createIntroduction, markDelivered, markDeliveryUnknown, respond, retryDelivery } from "./domain.js"; const start = 1_700_000_000_000; function fresh() { return createIntroduction({ requestId: "req-42", communityId: "community-typescript", requesterId: "member-a", recipientId: "member-b", note: "Would you be open to comparing accessibility testing workflows?", now: start, ttlMs: 60_000 }).introduction; } // Unknown delivery is retried as the same logical invitation. { const original = fresh(); const unknown = markDeliveryUnknown(original).introduction; const retried = retryDelivery(unknown, start + 1_000); assert.equal(retried.introduction.status, "sending"); assert.equal(retried.introduction.requestId, original.requestId); assert.equal(retried.introduction.deliveryKey, original.deliveryKey); assert.equal(retried.effects[0]?.type, "send_invitation"); } // A stale timeout cannot overwrite known delivery. { const delivered = markDelivered(fresh(), start + 100).introduction; const staleTimeout = markDeliveryUnknown(delivered).introduction; assert.equal(staleTimeout.status, "awaiting_response"); } // The requester cannot accept on the recipient's behalf. { const delivered = markDelivered(fresh(), start + 100).introduction; assert.throws( () => respond(delivered, "member-a", "accepted", start + 200), /Only the recipient/ ); } // Acceptance emits the direct-conversation effect exactly once. { const delivered = markDelivered(fresh(), start + 100).introduction; const accepted = respond( delivered, "member-b", "accepted", start + 200 ); assert.equal(accepted.introduction.status, "accepted"); assert.equal( accepted.effects.filter( (effect) => effect.type === "enable_direct_conversation" ).length, 1 ); const duplicate = respond( accepted.introduction, "member-b", "accepted", start + 300 ); assert.equal(duplicate.effects.length, 0); } // A response after expiry does not open a conversation. { const delivered = markDelivered(fresh(), start + 100).introduction; const late = respond( delivered, "member-b", "accepted", start + 60_001 ); assert.equal(late.introduction.status, "expired"); assert.equal(late.effects.length, 0); } console.log("All introduction workflow checks passed."); Run the checks: npm run check npm test Expected output: All introduction workflow checks passed. Make each failure visible without assigning social meaning A technically correct state machine can still produce a harmful interface if its labels speculate about people. Use factual copy: State Appropriate UI Avoid sending “Sending invitation…” “Connecting you now” delivery_unknown “Delivery could not be confirmed. Retry?” “They ignored your request” failed “Invitation was not sent” “Request rejected” awaiting_response “Waiting for a response” “Seen; no reply” unless read state is deliberately supported and disclosed declined “Invitation declined” Asking the recipient to justify the decision expired “Invitation expired” Automatically creating a replacement accepted “Introduction accepted” Opening additional channels the recipient did not approve Silence should remain silence. The application should not turn it into a story about the requester's worth or the recipient's intentions. Failure: acceptance is stored, but opening the conversation fails Do not roll the social decision back to awaiting_response. Acceptance occurred; the follow-up effect failed. In a production model, persist effects in an outbox transaction alongside the accepted state. Retry the enable_direct_conversation effect using requestId as its logical identity. Show “Accepted—preparing conversation” until that effect completes. Failure: the recipient blocks the requester after delivery Blocking should take precedence over the invitation workflow. Recheck current authorization and safety policy when processing a response or enabling a conversation. Do not assume that permission at send time remains valid forever. Failure: two devices answer differently The server should serialize the first final decision. A repeated identical answer is harmless; a conflicting answer receives the current final state instead of overwriting it. Failure: a moderator removes one member Expire or revoke pending invitations involving that member. Do not allow an old invitation card on another device to restore access. Translation should be a recipient-controlled view A multilingual community may let a recipient translate the invitation note before deciding. Keep the original note immutable and present translation as an on-demand view, not as a replacement source record. Tencent RTC provides official guidance for TUIChat message translation. Before exposing the control, verify the documented content-type, language, and edition constraints for the exact integration you are shipping. Translation failure should not change the invitation to declined or failed. It is a separate view-level state: type TranslationView = | { status: "original" } | { status: "translating"; targetLanguage: string } | { status: "translated"; targetLanguage: string; text: string } | { status: "unavailable"; targetLanguage: string }; The recipient should always be able to return to the original text. If translation is unavailable, the interface can offer another path rather than pretending that the social request itself failed. Verification checklist before connecting a real community Run the automated tests, then verify these integration behaviors in staging: [ ] Only authenticated community members can create eligible requests. [ ] The requester cannot target themselves. [ ] The recipient ID is authorized on the server, not trusted from the browser. [ ] Retrying uncertain delivery retains the same requestId and deliveryKey. [ ] Duplicate incoming invitation events render one card. [ ] A late timeout cannot overwrite confirmed delivery. [ ] Only the named recipient can accept or decline. [ ] Acceptance enables a direct conversation once. [ ] Decline sends no private-conversation effect. [ ] Expired invitations cannot be revived from an old browser tab. [ ] Blocking or member removal overrides pending invitations. [ ] Logs contain operational identifiers but not unnecessary invitation text. [ ] Translation is optional, visibly derived, and does not replace the source. [ ] The UI never interprets delivery failure as human rejection. The engineering skill underneath the feature If networking currently feels like a test of confidence, it helps to separate two things: Human uncertainty cannot be compiled away. Someone may still decline or not answer. Product ambiguity is an engineering choice. Delivery, consent, expiration, authorization, and recovery can be modeled clearly. The durable skill is not merely knowing how to place a Send button beside a text box. It is translating a social norm into states, permissions, and failure behavior without pretending the protocol can make the decision for either person. That is useful product engineering precisely because it respects what software cannot decide. Discussion Where would you place the boundary in your community: direct messages by default, invitation-only introductions, or member-configurable preferences? The right answer depends less on implementation convenience than on the social expectations your interface establishes. Relationship disclosure: I have a connection to Tencent RTC, and I used official Tencent RTC documentation as the implementation reference for this article.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to