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

> Source: <https://dev.to/susiewang/build-a-human-approved-ai-opportunity-bulletin-in-tencent-rtc-community-chat-1jkk>
> Published: 2026-08-22 13:18:32+00:00

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:

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](https://trtc.io/solutions/social-messaging).

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.

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
```

`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.

```
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
```

Add scripts to `package.json`

:

```
{
  "scripts": {
    "test": "tsx --test test/*.test.ts",
    "check": "tsc --noEmit"
  }
}
```

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.

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`

:

``` js
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" };
}
```

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.

Create `src/extractor.ts`

:

``` python
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();
}
```

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.

The moderator UI should show four things together:

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

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
    }
  };
}
```

For a correction after publication, create a new item with `replacesId`

pointing to the old item. That preserves what was actually reviewed and sent.

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>;
}
```

`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:

``` python
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");
}
```

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.

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](https://trtc.io/document/60772).

This ordering provides useful failure isolation:

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

Create `test/domain.test.ts`

:

``` python
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");
});
```

Run the checks:

```
npm test
npm run check
```

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.

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

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

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.

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.

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.

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.

Before connecting this workflow to a production community, verify:

`publishing`

is persisted before the send attempt.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.

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.
