DEV Community

LunarDrift
LunarDrift

Posted on

Don’t Open Another Feedback Channel: Build an Owned Listening Sprint in Community Chat

A familiar community problem starts with a reasonable request:

“Can we create a channel where people tell us what they need?”

The channel is easy. Ownership is hard.

Without a named responder, deadline, and visible conclusion, members contribute context but never learn what happened to it. Meanwhile, the developer or community lead responsible for the space may interpret low participation as a personal failure when the real issue is structural: nobody can see who must close the loop.

The durable skill here is not becoming better at opening channels. It is turning conversation into an accountable, time-bounded decision.

In this tutorial, we’ll build a listening sprint inside an existing community chat:

  1. A named owner opens one specific question.
  2. Members reply in the place where they already participate.
  3. Only consented replies enter the decision record.
  4. Collection closes at a published time.
  5. The owner posts a decision, including “no change.”
  6. A backup is notified if the owner misses the review deadline.

Tencent RTC’s Social Messaging solution covers group discussion and large-community scenarios, so this workflow fits inside the messaging experience rather than creating a separate feedback destination. See the official overview: https://trtc.io/solutions/social-messaging

Start with the operating contract

Before writing code, make the social contract explicit.

For this example, the community is considering whether to add weekly beginner office hours. The opening message should say:

  • what decision is being considered;
  • when collection closes;
  • who owns the response;
  • how replies will be used;
  • how a member can withdraw a contribution;
  • when the community will see the result.

A useful prompt is narrower than “Any feedback?”:

Listening sprint: Should we add weekly beginner office hours?

Owner: moderator-17
Collection closes: 2026-08-21 17:00 UTC
Decision due: 2026-08-22 17:00 UTC

Reply with the task you would bring to office hours. Replies may be
included in the decision record only with your consent. You can withdraw
before the decision is published.

We will close this thread with one of: proceed, run a limited trial,
do not proceed, or insufficient evidence.
Enter fullscreen mode Exit fullscreen mode

This wording reduces two ambiguities: members know whether someone is listening, and the owner knows what completion means.

Model the lifecycle before connecting chat

We will use six states:

DRAFT -> OPEN -> REVIEW -> DECIDED
                    |
                    v
                ESCALATED -> DECIDED

DRAFT/OPEN/REVIEW/ESCALATED -> CANCELLED
Enter fullscreen mode Exit fullscreen mode

The important distinction is between REVIEW and ESCALATED.

REVIEW means collection has ended and the named owner still has time to respond. ESCALATED means that deadline passed, so the backup may take responsibility. The sprint never silently becomes an abandoned chat thread.

Each accepted reply also keeps its original message reference. Chat remains the source conversation; the sprint record stores only the minimum data required to make and explain the decision.

Create the TypeScript project

Use Node.js 20 or later:

mkdir community-listening-sprint
cd community-listening-sprint
npm init -y
npm install --save-dev typescript tsx @types/node
mkdir src
npm pkg set type=module
npm pkg set scripts.test="tsx --test src/pulse.test.ts"
Enter fullscreen mode Exit fullscreen mode

Create src/pulse.ts:

export type Phase =
  | "DRAFT"
  | "OPEN"
  | "REVIEW"
  | "ESCALATED"
  | "DECIDED"
  | "CANCELLED";

export type Contribution = {
  sourceMessageId: string;
  contributorId: string;
  need: string;
  capturedAt: string;
  active: boolean;
};

export type OutboxItem = {
  id: string;
  text: string;
  status: "pending" | "sent";
  attempts: number;
  lastError?: string;
};

export type Sprint = {
  id: string;
  destinationId: string;
  question: string;
  ownerId: string;
  backupId: string;
  closesAt: string;
  reviewDueAt: string;
  phase: Phase;
  revision: number;
  seenEventIds: string[];
  contributions: Contribution[];
  decision?: {
    outcome: "proceed" | "trial" | "do-not-proceed" | "insufficient-evidence";
    explanation: string;
    publishedBy: string;
    publishedAt: string;
  };
  outbox: OutboxItem[];
};

export type Command =
  | { type: "OPEN"; eventId: string; actorId: string; at: string }
  | {
      type: "CAPTURE";
      eventId: string;
      messageId: string;
      contributorId: string;
      need: string;
      consent: boolean;
      at: string;
    }
  | {
      type: "WITHDRAW";
      eventId: string;
      messageId: string;
      contributorId: string;
      at: string;
    }
  | { type: "TICK"; eventId: string; at: string }
  | {
      type: "PUBLISH";
      eventId: string;
      actorId: string;
      outcome: Sprint["decision"] extends infer D
        ? D extends { outcome: infer O }
          ? O
          : never
        : never;
      explanation: string;
      at: string;
    }
  | {
      type: "CANCEL";
      eventId: string;
      actorId: string;
      reason: string;
      at: string;
    };

export function newSprint(
  input: Omit<
    Sprint,
    | "phase"
    | "revision"
    | "seenEventIds"
    | "contributions"
    | "outbox"
    | "decision"
  >,
): Sprint {
  return {
    ...input,
    phase: "DRAFT",
    revision: 0,
    seenEventIds: [],
    contributions: [],
    outbox: [],
  };
}

function beforeOrEqual(left: string, right: string): boolean {
  return Date.parse(left) <= Date.parse(right);
}

function queue(sprint: Sprint, text: string): void {
  sprint.outbox.push({
    id: `${sprint.id}:revision:${sprint.revision}`,
    text: `${text}\n\n[Listening sprint: ${sprint.id}, revision: ${sprint.revision}]`,
    status: "pending",
    attempts: 0,
  });
}

function requireActor(actual: string, expected: string, role: string): void {
  if (actual !== expected) {
    throw new Error(`Only the ${role} may perform this transition`);
  }
}

export function apply(input: Sprint, command: Command): Sprint {
  if (input.seenEventIds.includes(command.eventId)) return input;

  const sprint = structuredClone(input);
  sprint.revision += 1;

  switch (command.type) {
    case "OPEN": {
      if (sprint.phase !== "DRAFT") throw new Error("Sprint is not a draft");
      requireActor(command.actorId, sprint.ownerId, "owner");

      sprint.phase = "OPEN";
      queue(
        sprint,
        `Listening sprint opened: ${sprint.question}\n` +
          `Owner: ${sprint.ownerId}\n` +
          `Collection closes: ${sprint.closesAt}\n` +
          `Decision due: ${sprint.reviewDueAt}`,
      );
      break;
    }

    case "CAPTURE": {
      if (sprint.phase !== "OPEN") {
        throw new Error("Contributions are not being collected");
      }
      if (!beforeOrEqual(command.at, sprint.closesAt)) {
        throw new Error("Contribution arrived after collection closed");
      }
      if (!command.consent) {
        throw new Error("Contribution cannot be recorded without consent");
      }
      if (!command.need.trim()) throw new Error("Need cannot be empty");
      if (
        sprint.contributions.some(
          (item) => item.sourceMessageId === command.messageId,
        )
      ) {
        throw new Error("Message has already been captured");
      }

      sprint.contributions.push({
        sourceMessageId: command.messageId,
        contributorId: command.contributorId,
        need: command.need.trim(),
        capturedAt: command.at,
        active: true,
      });
      break;
    }

    case "WITHDRAW": {
      if (!["OPEN", "REVIEW", "ESCALATED"].includes(sprint.phase)) {
        throw new Error("Published or cancelled records cannot be withdrawn");
      }

      const contribution = sprint.contributions.find(
        (item) => item.sourceMessageId === command.messageId,
      );
      if (!contribution) throw new Error("Contribution was not found");
      if (contribution.contributorId !== command.contributorId) {
        throw new Error("A member may withdraw only their own contribution");
      }

      contribution.active = false;
      break;
    }

    case "TICK": {
      if (sprint.phase === "OPEN" && !beforeOrEqual(command.at, sprint.closesAt)) {
        sprint.phase = "REVIEW";
        queue(
          sprint,
          `Collection is closed. ${sprint.ownerId} is reviewing the responses.`,
        );
      } else if (
        sprint.phase === "REVIEW" &&
        !beforeOrEqual(command.at, sprint.reviewDueAt)
      ) {
        sprint.phase = "ESCALATED";
        queue(
          sprint,
          `The review deadline passed. Backup owner ${sprint.backupId} may now close the sprint.`,
        );
      }
      break;
    }

    case "PUBLISH": {
      if (sprint.phase === "REVIEW") {
        requireActor(command.actorId, sprint.ownerId, "owner");
      } else if (sprint.phase === "ESCALATED") {
        requireActor(command.actorId, sprint.backupId, "backup owner");
      } else {
        throw new Error("Sprint is not ready for a decision");
      }

      if (!command.explanation.trim()) {
        throw new Error("A decision requires an explanation");
      }

      sprint.phase = "DECIDED";
      sprint.decision = {
        outcome: command.outcome,
        explanation: command.explanation.trim(),
        publishedBy: command.actorId,
        publishedAt: command.at,
      };

      const activeCount = sprint.contributions.filter((item) => item.active).length;
      queue(
        sprint,
        `Decision: ${command.outcome}\n` +
          `${command.explanation.trim()}\n` +
          `Active contributions considered: ${activeCount}`,
      );
      break;
    }

    case "CANCEL": {
      if (["DECIDED", "CANCELLED"].includes(sprint.phase)) {
        throw new Error("Sprint is already closed");
      }
      if (![sprint.ownerId, sprint.backupId].includes(command.actorId)) {
        throw new Error("Only an owner may cancel the sprint");
      }
      if (!command.reason.trim()) throw new Error("Cancellation needs a reason");

      sprint.phase = "CANCELLED";
      queue(sprint, `Listening sprint cancelled: ${command.reason.trim()}`);
      break;
    }
  }

  sprint.seenEventIds.push(command.eventId);
  return sprint;
}
Enter fullscreen mode Exit fullscreen mode

The reducer has no network or database code. Given the same state and command, it produces the same next state. That makes races and policy decisions testable without requiring a live community.

Verify the policy with failure-oriented tests

Create src/pulse.test.ts:

import assert from "node:assert/strict";
import test from "node:test";
import { apply, newSprint, type Sprint } from "./pulse.js";

function draft(): Sprint {
  return newSprint({
    id: "office-hours-2026-08",
    destinationId: "community-group-42",
    question: "Should we add weekly beginner office hours?",
    ownerId: "moderator-17",
    backupId: "moderator-23",
    closesAt: "2026-08-21T17:00:00.000Z",
    reviewDueAt: "2026-08-22T17:00:00.000Z",
  });
}

function opened(): Sprint {
  return apply(draft(), {
    type: "OPEN",
    eventId: "event-open",
    actorId: "moderator-17",
    at: "2026-08-18T09:00:00.000Z",
  });
}

test("a duplicate event does not duplicate a contribution", () => {
  const command = {
    type: "CAPTURE" as const,
    eventId: "event-message-1",
    messageId: "message-1",
    contributorId: "member-8",
    need: "I need help understanding merge conflicts",
    consent: true,
    at: "2026-08-19T10:00:00.000Z",
  };

  const once = apply(opened(), command);
  const twice = apply(once, command);

  assert.equal(twice.contributions.length, 1);
  assert.equal(twice.revision, once.revision);
});

test("a reply cannot enter the record without consent", () => {
  assert.throws(
    () =>
      apply(opened(), {
        type: "CAPTURE",
        eventId: "event-message-2",
        messageId: "message-2",
        contributorId: "member-9",
        need: "I want a private code review",
        consent: false,
        at: "2026-08-19T11:00:00.000Z",
      }),
    /without consent/,
  );
});

test("a late reply is rejected even if the close timer has not run", () => {
  assert.throws(
    () =>
      apply(opened(), {
        type: "CAPTURE",
        eventId: "event-late-message",
        messageId: "message-late",
        contributorId: "member-10",
        need: "Help setting up a debugger",
        consent: true,
        at: "2026-08-21T17:00:01.000Z",
      }),
    /after collection closed/,
  );
});

test("the backup can publish only after escalation", () => {
  const review = apply(opened(), {
    type: "TICK",
    eventId: "event-close-tick",
    at: "2026-08-21T17:00:01.000Z",
  });

  assert.throws(
    () =>
      apply(review, {
        type: "PUBLISH",
        eventId: "event-early-backup",
        actorId: "moderator-23",
        outcome: "insufficient-evidence",
        explanation: "No consented needs were recorded.",
        at: "2026-08-22T12:00:00.000Z",
      }),
    /Only the owner/,
  );

  const escalated = apply(review, {
    type: "TICK",
    eventId: "event-escalation-tick",
    at: "2026-08-22T17:00:01.000Z",
  });

  const decided = apply(escalated, {
    type: "PUBLISH",
    eventId: "event-backup-decision",
    actorId: "moderator-23",
    outcome: "insufficient-evidence",
    explanation: "No consented needs were recorded, so we will not schedule a session yet.",
    at: "2026-08-22T17:05:00.000Z",
  });

  assert.equal(decided.phase, "DECIDED");
  assert.equal(decided.decision?.publishedBy, "moderator-23");
});

test("a member can withdraw before publication", () => {
  const captured = apply(opened(), {
    type: "CAPTURE",
    eventId: "event-message-3",
    messageId: "message-3",
    contributorId: "member-11",
    need: "I need help making my first contribution",
    consent: true,
    at: "2026-08-20T09:00:00.000Z",
  });

  const withdrawn = apply(captured, {
    type: "WITHDRAW",
    eventId: "event-withdraw-3",
    messageId: "message-3",
    contributorId: "member-11",
    at: "2026-08-20T10:00:00.000Z",
  });

  assert.equal(withdrawn.contributions[0].active, false);
});
Enter fullscreen mode Exit fullscreen mode

Run the suite:

npm test
Enter fullscreen mode Exit fullscreen mode

These tests verify policy, not SDK behavior. That separation matters: a successful chat callback does not prove that a late message was rejected, a withdrawal was honored, or the right person published the conclusion.

Put delivery behind an application-owned port

The core should not depend on a guessed SDK method name. Tencent RTC integration details can differ by target platform and the documented product surface you use.

Define an application interface instead:

import type { OutboxItem, Sprint } from "./pulse.js";

export interface CommunityChatPort {
  postText(destinationId: string, text: string): Promise<{ messageId: string }>;
}

export type SaveSprint = (sprint: Sprint) => Promise<void>;

export async function deliverPending(
  sprint: Sprint,
  chat: CommunityChatPort,
  save: SaveSprint,
): Promise<Sprint> {
  const next = structuredClone(sprint);

  for (const item of next.outbox.filter((entry) => entry.status === "pending")) {
    try {
      await chat.postText(next.destinationId, item.text);
      item.status = "sent";
      item.attempts += 1;
      delete item.lastError;
      await save(next);
    } catch (error) {
      item.attempts += 1;
      item.lastError = error instanceof Error ? error.message : String(error);
      await save(next);
      break;
    }
  }

  return next;
}
Enter fullscreen mode Exit fullscreen mode

CommunityChatPort is our interface, not the name of a Tencent RTC API. Its production adapter is where you map the documented Tencent RTC messaging operations and callbacks for your application.

The accompanying inbound adapter should convert only relevant chat activity into commands:

type IncomingReply = {
  eventId: string;
  messageId: string;
  senderId: string;
  text: string;
  sentAt: string;
  isReplyToSprintPrompt: boolean;
  consentRecorded: boolean;
};

function toCaptureCommand(message: IncomingReply) {
  if (!message.isReplyToSprintPrompt) return undefined;

  return {
    type: "CAPTURE" as const,
    eventId: message.eventId,
    messageId: message.messageId,
    contributorId: message.senderId,
    need: message.text,
    consent: message.consentRecorded,
    at: message.sentAt,
  };
}
Enter fullscreen mode Exit fullscreen mode

Do not ingest every message in the community. A listening sprint is a bounded interaction, not permission to turn ordinary conversation into an analytics dataset.

Delivery has an uncomfortable edge case

The outbox prevents a decision from disappearing merely because chat delivery was temporarily unavailable. It does not create exactly-once delivery.

Consider this sequence:

  1. postText succeeds.
  2. The process crashes before marking the outbox item as sent.
  3. The worker restarts and posts it again.

Unless your selected integration provides a documented idempotency mechanism, you must assume that duplicate delivery is possible. Do not invent one in the adapter.

The example appends a stable sprint-and-revision marker to every lifecycle message. That gives your application a deterministic reference for reconciliation and makes duplicates recognizable to moderators. Whether the adapter can automatically reconcile history depends on the documented capabilities of the integration you use.

For production persistence, save the state transition and its outbox item in one database transaction. The in-memory reducer demonstrates the policy, but it is not a substitute for durable storage.

Add translation as a reader-controlled view

A multilingual community can lose useful input when contributors feel pressured to write in the owner’s language. Tencent RTC documents on-demand text-message translation through TUIChat:

https://trtc.io/document/60772

Treat translation as a presentation feature, not a rewrite of the decision record:

type MessageView = {
  sourceMessageId: string;
  originalText: string;
  translatedText?: string;
  targetLanguage?: string;
};
Enter fullscreen mode Exit fullscreen mode

Keep the original text attached to the original message reference. A translated view may help a moderator understand a contribution, but it should not silently replace what the member wrote.

Before enabling the feature, check the official documentation for supported content types, languages, and applicable edition limits. Those constraints should shape the UI—for example, whether the translation action is shown—not be guessed by backend code.

Decide where the conversation belongs

Not every user need should enter a public listening sprint. Use a simple routing test:

Situation Better interaction
Several members may share the same need Bounded group or community listening sprint
The response contains account or personal information Move to an authorized private workflow
The question concerns behavior or safety Use the moderation/escalation process, not a public vote
The team has no owner or review deadline Do not open the sprint yet
The decision has already been made Publish the rationale instead of performing feedback collection

This avoids the most demoralizing version of community participation: asking people for input when nobody has authority or time to act on it.

Failure modes to rehearse before release

The timer worker runs late

A reply can arrive after closesAt while the sprint still says OPEN. That is why CAPTURE checks the timestamp independently of the timer-driven transition.

The displayed state may briefly lag, but the collection rule remains consistent.

The owner leaves the team

Do not edit historical ownership to make the record look tidy. Let the deadline move the sprint to ESCALATED, then allow the named backup to publish. The final record shows who actually made the decision.

A member deletes or withdraws a reply

The tutorial supports explicit withdrawal before publication. Your inbound adapter should also define what a source-message deletion means for your product and privacy policy.

A conservative policy is to mark the contribution inactive rather than retain copied content that the member intended to remove.

Chat delivery fails after the decision is saved

The decision remains DECIDED, while its outbox item stays pending. Retry delivery with backoff and alert an operator after a chosen attempt or age threshold.

Do not roll the business decision back merely because its notification failed.

There are no contributions

“No evidence” is still a conclusion. Publish insufficient-evidence and state what happens next. Quietly abandoning the prompt teaches members that future requests may also go nowhere.

The owner wants to summarize beyond the evidence

The state machine can enforce deadlines and authority, but it cannot make a summary fair. The reviewer should distinguish:

  • needs explicitly represented in active contributions;
  • interpretations made by the reviewer;
  • constraints supplied by the team;
  • the final decision.

That separation is a human judgment skill, not something another callback can automate.

Release checklist

Before connecting this workflow to a real community, verify:

  • [ ] The prompt names one decision, owner, backup, close time, and review deadline.
  • [ ] Members know when and how their replies enter the record.
  • [ ] Ordinary community messages are not collected automatically.
  • [ ] Duplicate inbound events do not create duplicate contributions.
  • [ ] Late replies are rejected even if a timer worker is delayed.
  • [ ] Contributors can withdraw before publication.
  • [ ] Only the owner—or backup after escalation—can publish.
  • [ ] “No change” and “insufficient evidence” are valid outcomes.
  • [ ] State and outbox writes share a durable transaction in production.
  • [ ] Delivery retries are observable.
  • [ ] Duplicate outbound delivery is treated as possible unless the selected integration explicitly documents otherwise.
  • [ ] Translation leaves the original message intact and follows documented support limits.
  • [ ] Cancellation and moderation paths are visible to members.

The skill that remains valuable

When a new tool or interface makes community creation easier, it can create an uncomfortable question for the person responsible for the space: If anyone can open a channel, what exactly is my role?

The answer is in the parts automation does not remove:

  • choosing a question narrow enough to answer;
  • deciding whether public discussion is appropriate;
  • obtaining meaningful consent;
  • assigning authority and backup ownership;
  • separating evidence from interpretation;
  • publishing a conclusion even when it is disappointing or inconclusive.

That is not merely channel administration. It is decision design.

A useful discussion question for your own team is: What is the oldest open community question for which nobody can name the owner, deadline, and possible outcomes? Start there before creating anything new.


Disclosure: I’m writing this article in connection with Tencent RTC. The official Tencent RTC Social Messaging solution page and TUIChat message-translation documentation were used as implementation references.

Top comments (0)