{"slug": "build-a-human-approved-ai-opportunity-bulletin-in-tencent-rtc-community-chat", "title": "Build a Human-Approved AI Opportunity Bulletin in Tencent RTC Community Chat", "summary": "A developer built a human-approved AI opportunity bulletin for Tencent RTC's social-messaging community, using a language model to extract candidate fields from submitted text while requiring moderators to verify authenticity and control publication. The workflow includes states for capture, extraction, review, approval, and publishing, with an explicit 'publish_unknown' state to handle delivery uncertainty. The system emphasizes that AI should only convert supplied material into a reviewable draft, not decide legitimacy or publish autonomously.", "body_md": "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?\n\nThe 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.\n\nIn this tutorial, we will build an opportunity bulletin for a Tencent RTC social-messaging community with:\n\nTencent 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).\n\nA 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.\n\nUse this division of responsibility:\n\n| Decision | Owner |\n|---|---|\n| Extract a possible deadline, organizer, reward, or eligibility statement | AI assistant |\n| Prove each extracted field came from the submitted text | Application validator |\n| Decide whether the source is trustworthy enough to share | Moderator |\n| Decide whether publication implies endorsement | Community policy |\n| Publish, reject, correct, or withdraw the post | Moderator-controlled workflow |\n| Translate the displayed post | Reader-controlled chat feature |\n\nThis reframes the human concern. Moderators are not there to polish AI prose; they are accountable for deciding what the community is willing to distribute.\n\nOur post will move through these states:\n\n```\ncaptured\n   └──> extracting\n           ├──> review\n           └──> extraction_failed\n\nreview\n   ├──> approved\n   ├──> rejected\n   └──> captured       (source edited; revision increases)\n\napproved\n   └──> publishing\n           ├──> published\n           ├──> approved         (confirmed not sent)\n           └──> publish_unknown  (delivery may have happened)\n\npublished\n   ├──> expired\n   └──> superseded by a new, reviewed revision\n```\n\n`publish_unknown`\n\nmatters. 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.\n\n```\nmkdir community-opportunity-desk\ncd community-opportunity-desk\nnpm init -y\nnpm install --save-dev typescript tsx @types/node\nnpx tsc --init\nmkdir -p src test\n```\n\nAdd scripts to `package.json`\n\n:\n\n```\n{\n  \"scripts\": {\n    \"test\": \"tsx --test test/*.test.ts\",\n    \"check\": \"tsc --noEmit\"\n  }\n}\n```\n\nThe 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.\n\nDo not ask the model for a polished summary alone. Require every extracted claim to identify the exact source characters supporting it.\n\nCreate `src/domain.ts`\n\n:\n\n``` js\nimport { createHash } from \"node:crypto\";\n\nexport type ClaimName =\n  | \"title\"\n  | \"organizer\"\n  | \"deadline\"\n  | \"eligibility\"\n  | \"reward\";\n\nexport type Claim = {\n  displayValue: string;\n  quote: string;\n  start: number;\n  end: number;\n};\n\nexport type Extraction = Partial<Record<ClaimName, Claim>>;\n\nexport type Opportunity = {\n  id: string;\n  revision: number;\n  sourceUrl: string;\n  sourceText: string;\n  submittedBy: string;\n  replacesId?: string;\n};\n\nexport type State =\n  | { phase: \"captured\"; item: Opportunity }\n  | { phase: \"extracting\"; item: Opportunity; requestedRevision: number }\n  | { phase: \"extraction_failed\"; item: Opportunity; reason: string }\n  | { phase: \"review\"; item: Opportunity; extraction: Extraction }\n  | {\n      phase: \"approved\";\n      item: Opportunity;\n      extraction: Extraction;\n      moderatorId: string;\n      approvedDigest: string;\n    }\n  | {\n      phase: \"publishing\";\n      item: Opportunity;\n      extraction: Extraction;\n      moderatorId: string;\n      approvedDigest: string;\n    }\n  | {\n      phase: \"published\";\n      item: Opportunity;\n      extraction: Extraction;\n      messageId: string;\n    }\n  | {\n      phase: \"publish_unknown\";\n      item: Opportunity;\n      extraction: Extraction;\n      reason: string;\n    }\n  | { phase: \"rejected\"; item: Opportunity; reason: string }\n  | { phase: \"expired\"; item: Opportunity; messageId: string };\n\nexport function validateExtraction(\n  sourceText: string,\n  extraction: Extraction\n): void {\n  for (const [name, claim] of Object.entries(extraction)) {\n    if (!claim) continue;\n\n    if (\n      !Number.isInteger(claim.start) ||\n      !Number.isInteger(claim.end) ||\n      claim.start < 0 ||\n      claim.end <= claim.start ||\n      claim.end > sourceText.length\n    ) {\n      throw new Error(`Invalid evidence span for ${name}`);\n    }\n\n    const actual = sourceText.slice(claim.start, claim.end);\n    if (actual !== claim.quote) {\n      throw new Error(`Evidence mismatch for ${name}`);\n    }\n\n    if (!claim.displayValue.trim()) {\n      throw new Error(`Empty display value for ${name}`);\n    }\n  }\n}\n\nfunction reviewDigest(\n  item: Opportunity,\n  extraction: Extraction\n): string {\n  return createHash(\"sha256\")\n    .update(\n      JSON.stringify({\n        id: item.id,\n        revision: item.revision,\n        sourceUrl: item.sourceUrl,\n        sourceText: item.sourceText,\n        extraction\n      })\n    )\n    .digest(\"hex\");\n}\n\nexport function requestExtraction(state: State): State {\n  if (state.phase !== \"captured\" && state.phase !== \"extraction_failed\") {\n    throw new Error(`Cannot extract from ${state.phase}`);\n  }\n\n  return {\n    phase: \"extracting\",\n    item: state.item,\n    requestedRevision: state.item.revision\n  };\n}\n\nexport function completeExtraction(\n  state: State,\n  revision: number,\n  extraction: Extraction\n): State {\n  if (state.phase !== \"extracting\") {\n    throw new Error(`Cannot complete extraction from ${state.phase}`);\n  }\n  if (revision !== state.requestedRevision) {\n    throw new Error(\"Stale extraction result\");\n  }\n\n  validateExtraction(state.item.sourceText, extraction);\n  return { phase: \"review\", item: state.item, extraction };\n}\n\nexport function approve(state: State, moderatorId: string): State {\n  if (state.phase !== \"review\") {\n    throw new Error(`Cannot approve from ${state.phase}`);\n  }\n\n  return {\n    ...state,\n    phase: \"approved\",\n    moderatorId,\n    approvedDigest: reviewDigest(state.item, state.extraction)\n  };\n}\n\nexport function beginPublishing(state: State): State {\n  if (state.phase !== \"approved\") {\n    throw new Error(`Cannot publish from ${state.phase}`);\n  }\n\n  const currentDigest = reviewDigest(state.item, state.extraction);\n  if (currentDigest !== state.approvedDigest) {\n    throw new Error(\"Approved content changed before publication\");\n  }\n\n  return { ...state, phase: \"publishing\" };\n}\n```\n\nThe 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.\n\nCreate `src/extractor.ts`\n\n:\n\n``` python\nimport type { Extraction, Opportunity } from \"./domain.js\";\n\nexport interface OpportunityExtractor {\n  extract(item: Opportunity): Promise<Extraction>;\n}\n\nexport function buildExtractionPrompt(item: Opportunity): string {\n  return `\nExtract only claims explicitly present in SOURCE_TEXT.\n\nReturn JSON with optional keys:\ntitle, organizer, deadline, eligibility, reward.\n\nEach value must contain:\n- displayValue: a concise rendering\n- quote: an exact substring copied from SOURCE_TEXT\n- start: zero-based start offset\n- end: exclusive end offset\n\nOmit unsupported fields. Do not infer missing dates, currencies,\neligibility rules, legitimacy, or endorsements.\n\nSOURCE_TEXT:\n${item.sourceText}\n`.trim();\n}\n```\n\nYour provider adapter should parse the response as untrusted input and pass it through `validateExtraction`\n\n. JSON mode or schema-constrained output can reduce formatting failures, but it does not remove the evidence or review requirements.\n\nAlso 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.\n\nThe moderator UI should show four things together:\n\nAvoid a generic “92% confidence” badge. It does not answer the decisions that matter:\n\nIf a moderator edits a factual field, create a new revision and require approval again. Do not silently mutate an approved object.\n\n```\nexport function editSource(\n  state: State,\n  sourceText: string,\n  sourceUrl: string\n): State {\n  if (state.phase === \"publishing\") {\n    throw new Error(\"Cannot edit during publication\");\n  }\n\n  return {\n    phase: \"captured\",\n    item: {\n      ...state.item,\n      revision: state.item.revision + 1,\n      sourceText,\n      sourceUrl\n    }\n  };\n}\n```\n\nFor a correction after publication, create a new item with `replacesId`\n\npointing to the old item. That preserves what was actually reviewed and sent.\n\nThe application should not infer delivery certainty from arbitrary exceptions. Define a chat port whose adapter reports one of three outcomes:\n\n```\nexport type DeliveryResult =\n  | { kind: \"confirmed\"; messageId: string }\n  | { kind: \"not_sent\"; reason: string }\n  | { kind: \"unknown\"; reason: string };\n\nexport interface CommunityChatPort {\n  sendCommunityPost(body: string): Promise<DeliveryResult>;\n}\n```\n\n`confirmed`\n\n: the connector received the platform's successful result and message identifier.`not_sent`\n\n: validation or another failure occurred before delivery was attempted.`unknown`\n\n: delivery was attempted, but the connector cannot prove whether it succeeded.Persist the `publishing`\n\nstate **before** calling the port. Then handle the result:\n\n``` python\nimport type { State } from \"./domain.js\";\nimport type { CommunityChatPort } from \"./chat-port.js\";\n\nexport async function publish(\n  state: Extract<State, { phase: \"publishing\" }>,\n  chat: CommunityChatPort,\n  persist: (next: State) => Promise<void>\n): Promise<State> {\n  // The caller must already have persisted `state`.\n  const result = await chat.sendCommunityPost(renderPost(state));\n\n  let next: State;\n  if (result.kind === \"confirmed\") {\n    next = {\n      phase: \"published\",\n      item: state.item,\n      extraction: state.extraction,\n      messageId: result.messageId\n    };\n  } else if (result.kind === \"not_sent\") {\n    next = { ...state, phase: \"approved\" };\n  } else {\n    next = {\n      phase: \"publish_unknown\",\n      item: state.item,\n      extraction: state.extraction,\n      reason: result.reason\n    };\n  }\n\n  await persist(next);\n  return next;\n}\n\nfunction renderPost(\n  state: Extract<State, { phase: \"publishing\" }>\n): string {\n  const value = (key: keyof typeof state.extraction) =>\n    state.extraction[key]?.displayValue ?? \"Not stated in submitted material\";\n\n  return [\n    `Community opportunity — moderator reviewed`,\n    `Title: ${value(\"title\")}`,\n    `Organizer: ${value(\"organizer\")}`,\n    `Deadline: ${value(\"deadline\")}`,\n    `Eligibility: ${value(\"eligibility\")}`,\n    `Reward/support: ${value(\"reward\")}`,\n    `Original source: ${state.item.sourceUrl}`,\n    `Verify current terms at the original source before applying.`,\n    `Reference: ${state.item.id}:r${state.item.revision}`,\n    `Questions or corrections? Contact a community moderator.`\n  ].join(\"\\n\");\n}\n```\n\nThe stable reference helps a moderator reconcile `publish_unknown`\n\n: 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.\n\nImplement `CommunityChatPort`\n\nusing 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.\n\nTranslation should not create a second canonical opportunity record. Publish the reviewed original, then let readers request a translated view.\n\nTUIChat 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).\n\nThis ordering provides useful failure isolation:\n\nKeep the source URL visible in every view. A translation can improve access, but it cannot verify legal language, eligibility, deadlines, or authenticity.\n\nCreate `test/domain.test.ts`\n\n:\n\n``` python\nimport test from \"node:test\";\nimport assert from \"node:assert/strict\";\nimport {\n  approve,\n  beginPublishing,\n  completeExtraction,\n  editSource,\n  requestExtraction,\n  type State\n} from \"../src/domain.js\";\n\nconst initial = (): State => ({\n  phase: \"captured\",\n  item: {\n    id: \"opp-42\",\n    revision: 1,\n    sourceUrl: \"https://example.test/opportunity\",\n    sourceText: \"Applications close on 30 September.\",\n    submittedBy: \"member-7\"\n  }\n});\n\nconst extraction = {\n  deadline: {\n    displayValue: \"30 September\",\n    quote: \"30 September\",\n    start: 22,\n    end: 34\n  }\n};\n\ntest(\"accepts evidence copied from the source\", () => {\n  const extracting = requestExtraction(initial());\n  const review = completeExtraction(extracting, 1, extraction);\n  assert.equal(review.phase, \"review\");\n});\n\ntest(\"rejects invented evidence\", () => {\n  const extracting = requestExtraction(initial());\n\n  assert.throws(\n    () =>\n      completeExtraction(extracting, 1, {\n        reward: {\n          displayValue: \"$10,000\",\n          quote: \"$10,000\",\n          start: 22,\n          end: 29\n        }\n      }),\n    /Evidence mismatch/\n  );\n});\n\ntest(\"an edit invalidates the previous workflow\", () => {\n  const extracting = requestExtraction(initial());\n  const edited = editSource(\n    extracting,\n    \"Applications are currently paused.\",\n    \"https://example.test/opportunity\"\n  );\n\n  assert.equal(edited.phase, \"captured\");\n  assert.equal(edited.item.revision, 2);\n  assert.throws(\n    () => completeExtraction(extracting, edited.item.revision, {}),\n    /Stale extraction result/\n  );\n});\n\ntest(\"only reviewed content can begin publication\", () => {\n  const review = completeExtraction(requestExtraction(initial()), 1, extraction);\n  const approved = approve(review, \"moderator-3\");\n  const publishing = beginPublishing(approved);\n\n  assert.equal(publishing.phase, \"publishing\");\n});\n```\n\nRun the checks:\n\n```\nnpm test\nnpm run check\n```\n\nAdd integration tests for the delivery port with three fakes: confirmed, definitely not sent, and unknown. Assert that only `not_sent`\n\nreturns to `approved`\n\n; `unknown`\n\nmust require reconciliation.\n\nThe result carries the requested revision. Reject it if the current revision differs. Never attach a late extraction to newer text.\n\nBecause the URL is included in the approval digest, publication must stop. Return the item to review rather than updating the link in place.\n\nOn restart, the database still says `publishing`\n\n. Move the item to `publish_unknown`\n\n, inspect the target conversation for its stable reference, and record the result. Do not automatically send again.\n\nCreate 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.\n\nThe 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.\n\nKeep 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.\n\nBefore connecting this workflow to a production community, verify:\n\n`publishing`\n\nis 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.\n\nThis 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.", "url": "https://wpnews.pro/news/build-a-human-approved-ai-opportunity-bulletin-in-tencent-rtc-community-chat", "canonical_source": "https://dev.to/susiewang/build-a-human-approved-ai-opportunity-bulletin-in-tencent-rtc-community-chat-1jkk", "published_at": "2026-08-22 13:18:32+00:00", "updated_at": "2026-08-22 13:43:27.991016+00:00", "lang": "en", "topics": ["artificial-intelligence", "developer-tools", "ai-agents", "ai-products"], "entities": ["Tencent RTC", "Social Messaging"], "alternates": {"html": "https://wpnews.pro/news/build-a-human-approved-ai-opportunity-bulletin-in-tencent-rtc-community-chat", "markdown": "https://wpnews.pro/news/build-a-human-approved-ai-opportunity-bulletin-in-tencent-rtc-community-chat.md", "text": "https://wpnews.pro/news/build-a-human-approved-ai-opportunity-bulletin-in-tencent-rtc-community-chat.txt", "jsonld": "https://wpnews.pro/news/build-a-human-approved-ai-opportunity-bulletin-in-tencent-rtc-community-chat.jsonld"}}