DEV Community

Build a Consent-First Welcome DM With Explicit Ownership

Joining a developer community can create an awkward tension: you want contact, but you do not necessarily want to announce your uncertainty in public. A welcome DM can lower that barrier. It can also make things worse if it arrives without consent, implies unlimited support, or leads to an inbox nobody is responsible for checking. The engineering problem is not simply how to send a message. It is how to create a small, understandable social contract: - A newcomer sees who is offering to talk and why. - The DM opens only after the newcomer accepts. - Every active conversation has a current owner. - Ownership changes are visible rather than silently dropped. - Either participant can close the conversation. - Translation is optional and does not replace the original message. This tutorial builds that contract as application state around a Tencent RTC Social Messaging integration. Tencent RTC's Social Messaging solution covers scenarios including communities, one-to-one chat, group discussion, and rich media. We will use the community and direct-message layers while keeping assignment, consent, and escalation in our own service. The interaction we are building A newcomer enters a community and sees a welcome card: Sam is hosting newcomer office hours until 16:00 UTC. Start a private welcome conversation? The newcomer can accept, decline, or ignore it. Accepting creates a bounded DM with Sam as its named owner. This is deliberately different from sending an automatic greeting to every new account. A chat thread is a relationship surface, so it should not exist before both the purpose and owner are known. Our lifecycle will be: OFFERED ──accept──> ACTIVE ──close──> CLOSED β”‚ β”‚ β”œβ”€β”€decline──> DECLINED β”œβ”€β”€timeout──> EXPIRED β”‚ β”‚ └──owner unavailable┴──> HANDOFF ──assign──> ACTIVE The important invariant is: An offered or active welcome conversation must have an accountable owner. A DM must not be opened before acceptance. Set up the TypeScript project mkdir community-welcome-dm cd community-welcome-dm npm init -y npm install --save-dev typescript tsx vitest @types/node npx tsc --init mkdir src Add these scripts to package.json : { "scripts": { "test": "vitest run", "check": "tsc --noEmit" } } The tutorial does not invent a Tencent RTC SDK method. Instead, it defines a narrow application port that you can map to the official client or server integration appropriate for your platform. That separation also lets us test the lifecycle without sending real messages. Represent the social contract as data Create src/welcome.ts : export type Phase = | 'offered' | 'active' | 'handoff' | 'declined' | 'expired' | 'closed'; export type WelcomeCase = { id: string; communityId: string; newcomerId: string; ownerId: string | null; phase: Phase; purpose: 'newcomer_welcome'; offerExpiresAt: string; conversationId: string | null; version: number; }; export type Event = | { type: 'ACCEPT'; at: string } | { type: 'DECLINE'; at: string } | { type: 'EXPIRE'; at: string } | { type: 'OWNER_UNAVAILABLE'; at: string } | { type: 'ASSIGN_OWNER'; ownerId: string; at: string } | { type: 'CLOSE'; actorId: string; at: string }; export type Effect = | { type: 'OPEN_DM'; newcomerId: string; ownerId: string; } | { type: 'SEND_HANDOFF_NOTICE'; conversationId: string; } | { type: 'SEND_NEW_OWNER_NOTICE'; conversationId: string; ownerId: string; }; export type Transition = { next: WelcomeCase; effects: Effect[]; }; conversationId starts as null . This matters: the offer is not itself a DM, and displaying a welcome card must not have the side effect of opening one. Now add the reducer: function assertPhase( current: WelcomeCase, allowed: Phase[], event: Event ): void { if (!allowed.includes(current.phase)) { throw new Error( Cannot apply ${event.type} while case is ${current.phase} ); } } export function evolve( current: WelcomeCase, event: Event ): Transition { switch (event.type) { case 'ACCEPT': { assertPhase(current, ['offered'], event); if (event.at >= current.offerExpiresAt) { return { next: { ...current, phase: 'expired', version: current.version + 1 }, effects: [] }; } if (!current.ownerId) { throw new Error('Cannot accept an unowned welcome offer'); } return { next: { ...current, phase: 'active', version: current.version + 1 }, effects: [{ type: 'OPEN_DM', newcomerId: current.newcomerId, ownerId: current.ownerId }] }; } case 'DECLINE': assertPhase(current, ['offered'], event); return { next: { ...current, phase: 'declined', version: current.version + 1 }, effects: [] }; case 'EXPIRE': assertPhase(current, ['offered'], event); return { next: { ...current, phase: 'expired', version: current.version + 1 }, effects: [] }; case 'OWNER_UNAVAILABLE': { assertPhase(current, ['offered', 'active'], event); const effects: Effect[] = current.conversationId ? [{ type: 'SEND_HANDOFF_NOTICE', conversationId: current.conversationId }] : []; return { next: { ...current, phase: 'handoff', ownerId: null, version: current.version + 1 }, effects }; } case 'ASSIGN_OWNER': { assertPhase(current, ['handoff'], event); const effects: Effect[] = current.conversationId ? [{ type: 'SEND_NEW_OWNER_NOTICE', conversationId: current.conversationId, ownerId: event.ownerId }] : []; return { next: { ...current, phase: current.conversationId ? 'active' : 'offered', ownerId: event.ownerId, version: current.version + 1 }, effects }; } case 'CLOSE': assertPhase(current, ['active', 'handoff'], event); return { next: { ...current, phase: 'closed', version: current.version + 1 }, effects: [] }; } } The reducer does not send messages. It decides what should happen and emits effects for a separate delivery worker. That distinction protects us from a common failure: the database update succeeds, the message request times out, and a retry opens a second conversation. Persist transitions and effects together A minimal relational model can use one table for current state and an outbox for delivery work: CREATE TABLE welcome_cases ( id TEXT PRIMARY KEY, community_id TEXT NOT NULL, newcomer_id TEXT NOT NULL, owner_id TEXT, phase TEXT NOT NULL, purpose TEXT NOT NULL, offer_expires_at TEXT NOT NULL, conversation_id TEXT, version INTEGER NOT NULL ); CREATE TABLE welcome_outbox ( id TEXT PRIMARY KEY, case_id TEXT NOT NULL, case_version INTEGER NOT NULL, effect_type TEXT NOT NULL, payload_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', UNIQUE(case_id, case_version, effect_type) ); Process an event inside one database transaction: - Read the case and its version. - Run evolve . - Update the case only if the stored version still matches. - Insert each emitted effect into the outbox. - Commit. The unique constraint gives each effect a stable identity. If the HTTP request is retried after an uncertain response, the same transition cannot enqueue duplicate work. A production update should resemble: UPDATE welcome_cases SET phase = ?, owner_id = ?, version = ?, conversation_id = ? WHERE id = ? AND version = ?; If zero rows are updated, another request won the race. Reload the case instead of guessing whether acceptance, expiration, or reassignment happened first. Put Tencent RTC behind a delivery port Define the behavior the application needs: export interface ChatPort { openDirectConversation(input: { operationId: string; newcomerId: string; ownerId: string; }): Promise ; sendSystemMessage(input: { operationId: string; conversationId: string; text: string; }): Promise ; } These are application-owned method names, not Tencent RTC API names. Implement this adapter using the documented Tencent RTC messaging integration selected for your target platform. The worker then translates durable effects into chat operations: export async function deliver( effectId: string, caseId: string, effect: Effect, chat: ChatPort, saveConversationId: ( caseId: string, conversationId: string ) => Promise ): Promise { switch (effect.type) { case 'OPEN_DM': { const result = await chat.openDirectConversation({ operationId: effectId, newcomerId: effect.newcomerId, ownerId: effect.ownerId }); await saveConversationId(caseId, result.conversationId); return; } case 'SEND_HANDOFF_NOTICE': await chat.sendSystemMessage({ operationId: effectId, conversationId: effect.conversationId, text: 'Your current host is unavailable. This conversation is waiting for a new host.' }); return; case 'SEND_NEW_OWNER_NOTICE': await chat.sendSystemMessage({ operationId: effectId, conversationId: effect.conversationId, text: 'A new community host has taken ownership of this welcome conversation.' }); } } For safe retries, the concrete adapter should preserve operationId as an idempotency or deduplication key where the selected integration permits it. If the integration cannot guarantee that, store the remote result before acknowledging the outbox item and reconcile uncertain outcomes rather than blindly repeating them. Keep outbox states such as pending , delivering , delivered , and needs_review . After a bounded number of uncertain attempts, move the item to needs_review ; do not claim success to the newcomer. Make scope visible inside the DM The first message should state the contract rather than pretending that a volunteer host is permanent support: Welcome! This is a private newcomer conversation with your current community host. Good topics: finding the right discussion area, understanding community norms, and choosing a first way to participate. For account, billing, security, or product support, use the community's published support route. You can close this conversation at any time. This wording helps both participants. The newcomer does not need to perform confidence, and the host does not become responsible for every problem raised in chat. The interface should also provide visible Close, Report, and Leave conversation controls according to your application's safety policy. Define who can access reported content, what context is attached, and how long it is retained. Do not treat all private conversations as moderator-visible by default m

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.