Dev.to · 12 min read

Build a Consent-First Welcome DM With Explicit Ownership

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 merely because moderation may eventually be needed. Add translation without changing the record Language can be a participation barrier, but automatic replacement creates ambiguity about what the sender actually wrote. Tencent RTC documents on-demand text-message translation in TUIChat. Supported content types, languages, and edition constraints should be checked against the current TUIChat message translation documentation before enabling the control. Use three rules: Translation is requested by the reader. The original message remains available. Translation failure does not hide or mutate the original. A small presentation state is enough: type TranslationState = | { status: 'idle' } | { status: 'loading' } | { status: 'visible'; translatedText: string } | { status: 'unavailable'; reason: string }; If a content type or language is unsupported, show the original message with a neutral unavailable state. Do not repeatedly retry as if translation were required for message delivery. Also avoid storing translated text as the canonical moderation record. A report should identify the original message and may attach the displayed translation as derived context. Test the lifecycle before connecting chat Create src/welcome.test.ts: import { describe, expect, it } from 'vitest'; import { evolve, type WelcomeCase } from './welcome'; const offered = (): WelcomeCase => ({ id: 'case-1', communityId: 'community-1', newcomerId: 'newcomer-1', ownerId: 'host-1', phase: 'offered', purpose: 'newcomer_welcome', offerExpiresAt: '2026-08-11T16:00:00.000Z', conversationId: null, version: 1 }); describe('welcome lifecycle', () => { it('opens a DM only after timely acceptance', () => { const result = evolve(offered(), { type: 'ACCEPT', at: '2026-08-11T15:30:00.000Z' }); expect(result.next.phase).toBe('active'); expect(result.effects).toEqual([{ type: 'OPEN_DM', newcomerId: 'newcomer-1', ownerId: 'host-1' }]); }); it('does not open a DM after expiration', () => { const result = evolve(offered(), { type: 'ACCEPT', at: '2026-08-11T16:00:00.000Z' }); expect(result.next.phase).toBe('expired'); expect(result.effects).toEqual([]); }); it('moves an active conversation into visible handoff', () => { const active: WelcomeCase = { ...offered(), phase: 'active', conversationId: 'conversation-1' }; const result = evolve(active, { type: 'OWNER_UNAVAILABLE', at: '2026-08-11T15:40:00.000Z' }); expect(result.next.phase).toBe('handoff'); expect(result.next.ownerId).toBeNull(); expect(result.effects[0]?.type).toBe('SEND_HANDOFF_NOTICE'); }); it('rejects a second acceptance', () => { const active: WelcomeCase = { ...offered(), phase: 'active' }; expect(() => evolve(active, { type: 'ACCEPT', at: '2026-08-11T15:45:00.000Z' })).toThrow(); }); }); Run both checks: npm run check npm test Failure drills that matter more than the happy path Acceptance and expiration arrive together Use optimistic version checks. Only one transition commits. The losing request reloads the durable result and returns it to the client. Do not compare the expiry time only in the browser; client clocks and delayed requests are not authoritative. The DM opens, but saving its ID fails This is an uncertain delivery outcome. Retrying without deduplication could create another conversation. Keep the outbox item unresolved, reconcile using its stable operation ID if supported by the integration, and send it to manual review if the remote result cannot be determined safely. A host leaves during an active conversation Move the case to handoff, remove the owner, and tell the newcomer what changed. Do not leave the old host's name displayed while routing messages elsewhere. A service-level policy can decide whether handoff cases are assigned automatically or reviewed by a coordinator, but the state must remain visible either way. No replacement host is available Close the welcome path honestly and provide the existing public community route. An explicit closure is better than preserving an active-looking DM with no reader. Translation is unavailable Keep the original text readable. Show that translation is unavailable for this message rather than turning a presentation failure into a message-delivery failure. The newcomer reports the host Freeze automatic reassignment for that case, preserve only the evidence required by the published moderation policy, and route it to an authorized human reviewer. Do not send the reported content to a random replacement host. Verification checklist Before release, verify the workflow with two test accounts and a host account: [ ] Viewing the welcome card does not create a DM. [ ] Declining or ignoring the offer sends no private message. [ ] An expired offer cannot be accepted. [ ] Double-clicking Accept results in one durable transition. [ ] Every offered or active case displays its current owner. [ ] Removing a host changes the case to handoff. [ ] A handoff is visible to the newcomer. [ ] Either participant can close the conversation. [ ] Delivery failures remain pending or enter review; they are not shown as successful. [ ] Translation is user-triggered and leaves the original visible. [ ] Unsupported translation does not block the conversation. [ ] Reporting follows a documented access and retention policy. [ ] Account, security, and product-support questions have a route outside the welcome DM. The trade-off: fewer conversations, stronger promises Consent and host capacity will reduce the number of welcome DMs you can open. That is not necessarily a defect. A community does not become more welcoming by maximizing message count. It becomes more trustworthy when a visible invitation means what it says: a real person has accepted a limited, understandable responsibility. For someone early in their career, this distinction can be especially useful. Asking a small question is not evidence that they do not belong. But the system should not require them to guess whether anyone is listening, whether they are imposing, or whether a private message has silently become a support ticket. If you operate community onboarding, what should a welcome host own—and which requests should leave the DM immediately? Relationship disclosure: I have a connection to Tencent RTC, and I used the official Tencent RTC Social Messaging and TUIChat documentation as implementation references 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

More Programming & Dev News