DEV Community

LunarDrift
LunarDrift

Posted on

Build a Human-Approved AI Opportunity Bulletin in Tencent RTC Community Chat

A community opportunity post creates an awkward tension: members want timely information, but publishing it can look like an endorsement. Add AI summarization and the ambiguity gets worse. Did a person verify the deadline and eligibility, or did a model confidently fill in missing details?

The useful role for AI here is narrow: convert supplied material into a reviewable draft. It should not decide whether an opportunity is legitimate, rank who deserves it, or publish on its own.

In this tutorial, we will build an opportunity bulletin for a Tencent RTC social-messaging community with:

  • evidence-linked AI extraction;
  • explicit review and delivery states;
  • human approval tied to an immutable revision;
  • safe handling of stale model responses;
  • no automatic retry after an uncertain send;
  • a visible route back to a moderator;
  • optional, reader-controlled translation.

Tencent RTC's Social Messaging solution covers group discussions, large communities, 1-to-1 chat, rich media, and related social experiences. That makes the bulletin a workflow inside the community conversation rather than a separate publishing system: Social Messaging solution.

Decide what AI is allowed to do

A language model can demonstrate that it can extract candidate fields from supplied text. That does not demonstrate that the source is authentic or that its terms are still current.

Use this division of responsibility:

Decision Owner
Extract a possible deadline, organizer, reward, or eligibility statement AI assistant
Prove each extracted field came from the submitted text Application validator
Decide whether the source is trustworthy enough to share Moderator
Decide whether publication implies endorsement Community policy
Publish, reject, correct, or withdraw the post Moderator-controlled workflow
Translate the displayed post Reader-controlled chat feature

This reframes the human concern. Moderators are not there to polish AI prose; they are accountable for deciding what the community is willing to distribute.

The lifecycle we need

Our post will move through these states:

captured
   └──> extracting
           ├──> review
           └──> extraction_failed

review
   ├──> approved
   ├──> rejected
   └──> captured       (source edited; revision increases)

approved
   └──> publishing
           ├──> published
           ├──> approved         (confirmed not sent)
           └──> publish_unknown  (delivery may have happened)

published
   ├──> expired
   └──> superseded by a new, reviewed revision
Enter fullscreen mode Exit fullscreen mode

publish_unknown matters. If the chat service accepted a message but the client lost the response, blindly retrying may create a duplicate. That uncertainty is a real state, not an exception to hide.

Create the TypeScript project

mkdir community-opportunity-desk
cd community-opportunity-desk
npm init -y
npm install --save-dev typescript tsx @types/node
npx tsc --init
mkdir -p src test
Enter fullscreen mode Exit fullscreen mode

Add scripts to package.json:

{
  "scripts": {
    "test": "tsx --test test/*.test.ts",
    "check": "tsc --noEmit"
  }
}
Enter fullscreen mode Exit fullscreen mode

The domain code will not depend on an AI vendor or chat SDK. Those integrations sit behind ports so we can test the risky transitions locally.

Represent claims with evidence spans

Do not ask the model for a polished summary alone. Require every extracted claim to identify the exact source characters supporting it.

Create src/domain.ts:

import { createHash } from "node:crypto";

export type ClaimName =
  | "title"
  | "organizer"
  | "deadline"
  | "eligibility"
  | "reward";

export type Claim = {
  displayValue: string;
  quote: string;
  start: number;
  end: number;
};

export type Extraction = Partial<Record<ClaimName, Claim>>;

export type Opportunity = {
  id: string;
  revision: number;
  sourceUrl: string;
  sourceText: string;
  submittedBy: string;
  replacesId?: string;
};

export type State =
  | { phase: "captured"; item: Opportunity }
  | { phase: "extracting"; item: Opportunity; requestedRevision: number }
  | { phase: "extraction_failed"; item: Opportunity; reason: string }
  | { phase: "review"; item: Opportunity; extraction: Extraction }
  | {
      phase: "approved";
      item: Opportunity;
      extraction: Extraction;
      moderatorId: string;
      approvedDigest: string;
    }
  | {
      phase: "publishing";
      item: Opportunity;
      extraction: Extraction;
      moderatorId: string;
      approvedDigest: string;
    }
  | {
      phase: "published";
      item: Opportunity;
      extraction: Extraction;
      messageId: string;
    }
  | {
      phase: "publish_unknown";
      item: Opportunity;
      extraction: Extraction;
      reason: string;
    }
  | { phase: "rejected"; item: Opportunity; reason: string }
  | { phase: "expired"; item: Opportunity; messageId: string };

export function validateExtraction(
  sourceText: string,
  extraction: Extraction
): void {
  for (const [name, claim] of Object.entries(extraction)) {
    if (!claim) continue;

    if (
      !Number.isInteger(claim.start) ||
      !Number.isInteger(claim.end) ||
      claim.start < 0 ||
      claim.end <= claim.start ||
      claim.end > sourceText.length
    ) {
      throw new Error(`Invalid evidence span for ${name}`);
    }

    const actual = sourceText.slice(claim.start, claim.end);
    if (actual !== claim.quote) {
      throw new Error(`Evidence mismatch for ${name}`);
    }

    if (!claim.displayValue.trim()) {
      throw new Error(`Empty display value for ${name}`);
    }
  }
}

function reviewDigest(
  item: Opportunity,
  extraction: Extraction
): string {
  return createHash("sha256")
    .update(
      JSON.stringify({
        id: item.id,
        revision: item.revision,
        sourceUrl: item.sourceUrl,
        sourceText: item.sourceText,
        extraction
      })
    )
    .digest("hex");
}

export function requestExtraction(state: State): State {
  if (state.phase !== "captured" && state.phase !== "extraction_failed") {
    throw new Error(`Cannot extract from ${state.phase}`);
  }

  return {
    phase: "extracting",
    item: state.item,
    requestedRevision: state.item.revision
  };
}

export function completeExtraction(
  state: State,
  revision: number,
  extraction: Extraction
): State {
  if (state.phase !== "extracting") {
    throw new Error(`Cannot complete extraction from ${state.phase}`);
  }
  if (revision !== state.requestedRevision) {
    throw new Error("Stale extraction result");
  }

  validateExtraction(state.item.sourceText, extraction);
  return { phase: "review", item: state.item, extraction };
}

export function approve(state: State, moderatorId: string): State {
  if (state.phase !== "review") {
    throw new Error(`Cannot approve from ${state.phase}`);
  }

  return {
    ...state,
    phase: "approved",
    moderatorId,
    approvedDigest: reviewDigest(state.item, state.extraction)
  };
}

export function beginPublishing(state: State): State {
  if (state.phase !== "approved") {
    throw new Error(`Cannot publish from ${state.phase}`);
  }

  const currentDigest = reviewDigest(state.item, state.extraction);
  if (currentDigest !== state.approvedDigest) {
    throw new Error("Approved content changed before publication");
  }

  return { ...state, phase: "publishing" };
}
Enter fullscreen mode Exit fullscreen mode

The evidence check does not establish truth. It establishes the smaller but valuable fact that the model did not produce a claim with no corresponding source span.

Put the model behind an extraction port

Create src/extractor.ts:

import type { Extraction, Opportunity } from "./domain.js";

export interface OpportunityExtractor {
  extract(item: Opportunity): Promise<Extraction>;
}

export function buildExtractionPrompt(item: Opportunity): string {
  return `
Extract only claims explicitly present in SOURCE_TEXT.

Return JSON with optional keys:
title, organizer, deadline, eligibility, reward.

Each value must contain:
- displayValue: a concise rendering
- quote: an exact substring copied from SOURCE_TEXT
- start: zero-based start offset
- end: exclusive end offset

Omit unsupported fields. Do not infer missing dates, currencies,
eligibility rules, legitimacy, or endorsements.

SOURCE_TEXT:
${item.sourceText}
`.trim();
}
Enter fullscreen mode Exit fullscreen mode

Your provider adapter should parse the response as untrusted input and pass it through validateExtraction. JSON mode or schema-constrained output can reduce formatting failures, but it does not remove the evidence or review requirements.

Also decide what may be sent to the model. A practical submission form should state that the supplied text will be processed to create a draft. Do not forward private messages, email addresses, application answers, or unrelated conversation history just because they are available in the chat client.

Make review a comparison, not a confidence score

The moderator UI should show four things together:

  1. The original source URL.
  2. The complete submitted source text.
  3. Each extracted value beside its highlighted evidence span.
  4. Approve, edit, reject, and request-more-information actions.

Avoid a generic “92% confidence” badge. It does not answer the decisions that matter:

  • Is the organizer identifiable?
  • Does the deadline include a timezone?
  • Are regional or age restrictions easy to miss?
  • Is a prize described as guaranteed, conditional, or merely possible?
  • Is the community comfortable distributing this source?

If a moderator edits a factual field, create a new revision and require approval again. Do not silently mutate an approved object.

export function editSource(
  state: State,
  sourceText: string,
  sourceUrl: string
): State {
  if (state.phase === "publishing") {
    throw new Error("Cannot edit during publication");
  }

  return {
    phase: "captured",
    item: {
      ...state.item,
      revision: state.item.revision + 1,
      sourceText,
      sourceUrl
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

For a correction after publication, create a new item with replacesId pointing to the old item. That preserves what was actually reviewed and sent.

Treat chat delivery as a three-outcome operation

The application should not infer delivery certainty from arbitrary exceptions. Define a chat port whose adapter reports one of three outcomes:

export type DeliveryResult =
  | { kind: "confirmed"; messageId: string }
  | { kind: "not_sent"; reason: string }
  | { kind: "unknown"; reason: string };

export interface CommunityChatPort {
  sendCommunityPost(body: string): Promise<DeliveryResult>;
}
Enter fullscreen mode Exit fullscreen mode
  • confirmed: the connector received the platform's successful result and message identifier.
  • not_sent: validation or another failure occurred before delivery was attempted.
  • unknown: delivery was attempted, but the connector cannot prove whether it succeeded.

Persist the publishing state before calling the port. Then handle the result:

import type { State } from "./domain.js";
import type { CommunityChatPort } from "./chat-port.js";

export async function publish(
  state: Extract<State, { phase: "publishing" }>,
  chat: CommunityChatPort,
  persist: (next: State) => Promise<void>
): Promise<State> {
  // The caller must already have persisted `state`.
  const result = await chat.sendCommunityPost(renderPost(state));

  let next: State;
  if (result.kind === "confirmed") {
    next = {
      phase: "published",
      item: state.item,
      extraction: state.extraction,
      messageId: result.messageId
    };
  } else if (result.kind === "not_sent") {
    next = { ...state, phase: "approved" };
  } else {
    next = {
      phase: "publish_unknown",
      item: state.item,
      extraction: state.extraction,
      reason: result.reason
    };
  }

  await persist(next);
  return next;
}

function renderPost(
  state: Extract<State, { phase: "publishing" }>
): string {
  const value = (key: keyof typeof state.extraction) =>
    state.extraction[key]?.displayValue ?? "Not stated in submitted material";

  return [
    `Community opportunity — moderator reviewed`,
    `Title: ${value("title")}`,
    `Organizer: ${value("organizer")}`,
    `Deadline: ${value("deadline")}`,
    `Eligibility: ${value("eligibility")}`,
    `Reward/support: ${value("reward")}`,
    `Original source: ${state.item.sourceUrl}`,
    `Verify current terms at the original source before applying.`,
    `Reference: ${state.item.id}:r${state.item.revision}`,
    `Questions or corrections? Contact a community moderator.`
  ].join("\n");
}
Enter fullscreen mode Exit fullscreen mode

The stable reference helps a moderator reconcile publish_unknown: inspect the target conversation for that reference before deciding to retry. Do not assume an SDK offers idempotency unless the exact version you use documents it.

Implement CommunityChatPort using the Tencent RTC Chat integration selected for your application. The official Social Messaging page is the appropriate starting point for choosing between 1-to-1, group, or larger community experiences. The port above is application-owned; it intentionally avoids inventing a Tencent RTC API name that may not match your SDK or version.

Keep translation downstream of approval

Translation should not create a second canonical opportunity record. Publish the reviewed original, then let readers request a translated view.

TUIChat documents on-demand translation for text messages. Supported content types, languages, and edition limits must be checked against the current documentation before you make it part of the product contract: TUIChat message translation.

This ordering provides useful failure isolation:

  • extraction failure blocks review;
  • moderation rejection blocks publication;
  • publication failure blocks the post;
  • translation failure does not alter or remove the reviewed original.

Keep the source URL visible in every view. A translation can improve access, but it cannot verify legal language, eligibility, deadlines, or authenticity.

Reproduce the dangerous races

Create test/domain.test.ts:

import test from "node:test";
import assert from "node:assert/strict";
import {
  approve,
  beginPublishing,
  completeExtraction,
  editSource,
  requestExtraction,
  type State
} from "../src/domain.js";

const initial = (): State => ({
  phase: "captured",
  item: {
    id: "opp-42",
    revision: 1,
    sourceUrl: "https://example.test/opportunity",
    sourceText: "Applications close on 30 September.",
    submittedBy: "member-7"
  }
});

const extraction = {
  deadline: {
    displayValue: "30 September",
    quote: "30 September",
    start: 22,
    end: 34
  }
};

test("accepts evidence copied from the source", () => {
  const extracting = requestExtraction(initial());
  const review = completeExtraction(extracting, 1, extraction);
  assert.equal(review.phase, "review");
});

test("rejects invented evidence", () => {
  const extracting = requestExtraction(initial());

  assert.throws(
    () =>
      completeExtraction(extracting, 1, {
        reward: {
          displayValue: "$10,000",
          quote: "$10,000",
          start: 22,
          end: 29
        }
      }),
    /Evidence mismatch/
  );
});

test("an edit invalidates the previous workflow", () => {
  const extracting = requestExtraction(initial());
  const edited = editSource(
    extracting,
    "Applications are currently paused.",
    "https://example.test/opportunity"
  );

  assert.equal(edited.phase, "captured");
  assert.equal(edited.item.revision, 2);
  assert.throws(
    () => completeExtraction(extracting, edited.item.revision, {}),
    /Stale extraction result/
  );
});

test("only reviewed content can begin publication", () => {
  const review = completeExtraction(requestExtraction(initial()), 1, extraction);
  const approved = approve(review, "moderator-3");
  const publishing = beginPublishing(approved);

  assert.equal(publishing.phase, "publishing");
});
Enter fullscreen mode Exit fullscreen mode

Run the checks:

npm test
npm run check
Enter fullscreen mode Exit fullscreen mode

Add integration tests for the delivery port with three fakes: confirmed, definitely not sent, and unknown. Assert that only not_sent returns to approved; unknown must require reconciliation.

Failure drills for staging

The model responds after a moderator edits the source

The result carries the requested revision. Reject it if the current revision differs. Never attach a late extraction to newer text.

The source URL changes after approval

Because the URL is included in the approval digest, publication must stop. Return the item to review rather than updating the link in place.

The process crashes after sending

On restart, the database still says publishing. Move the item to publish_unknown, inspect the target conversation for its stable reference, and record the result. Do not automatically send again.

A deadline changes after publication

Create a corrected revision, review it, and publish a clearly labeled correction referring to the previous post. If your application also edits or removes the old message, record that as a separate moderation action rather than rewriting history invisibly.

A member disputes legitimacy

The visible “contact a moderator” route is the human handoff. Preserve the disputed post reference, source URL, and reviewed revision. Pause or label the item according to community policy while a person investigates; do not ask the model to adjudicate the dispute.

Translation is unavailable

Keep the original reviewed message accessible and show that translation could not be produced. Do not replace it with an unreviewed server-side AI translation as a silent fallback.

Ship/no-ship checklist

Before connecting this workflow to a production community, verify:

  • [ ] AI output is treated as untrusted structured input.
  • [ ] Every extracted claim contains an exact evidence span.
  • [ ] Missing fields remain missing instead of being inferred.
  • [ ] A moderator sees the original source and extracted claims together.
  • [ ] Approval is bound to the source, revision, URL, and extracted fields.
  • [ ] Editing any approved content requires another review.
  • [ ] publishing is persisted before the send attempt.
  • [ ] Uncertain delivery never triggers an automatic retry.
  • [ ] Published posts include the original source and a stable reference.
  • [ ] Members have a visible path to a human moderator.
  • [ ] Corrections preserve the relationship to the previous revision.
  • [ ] Translation remains an optional view of the reviewed original.
  • [ ] Submission consent and data-retention rules are visible.

AI earns its place here when it reduces the work of locating and formatting claims. It fails when extraction is mistaken for verification or when speed quietly removes human accountability. The practical next step is not a more elaborate prompt: implement the evidence span, approval digest, and uncertain-delivery state first. Those boundaries remain useful regardless of which model or chat connector you choose.

Relationship disclosure

This article was produced in connection with Tencent RTC. I used the official Tencent RTC Social Messaging solution page and TUIChat message translation documentation as implementation references.

Top comments (0)