2026 03 21 Vitto Unified Channel Completion

Documentation for Plans

Vitto Unified Channel Completion — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Wire Vitto's dormant messaging tools, add channel adapters for Slack/Telegram/Discord, handle interactive callbacks, and build a minimal group management frontend — making the S117-S218 channel infrastructure production-ready.

Architecture: The existing pipeline (webhook → ChannelGateway → engine.submitTask → ChannelProcessingService → agent loop → routeTaskResponse → sendResponse) is solid and untouched. We fix 2 dependency injection lines (Layer 1), add 3 adapters + interaction handlers (Layer 2), and build frontend group UI (Layer 3).

Tech Stack: NestJS, Angular 20+, Slack Block Kit, Telegram Bot API, Discord Components API, existing ChannelAdapter interface + AdapterRegistryService.

Spec: docs/superpowers/specs/2026-03-21-vitto-unified-channel-completion-design.md

Git convention: [S250.{sub}] type: description — no git actions until user confirms (concurrent session constraint).


Task 1: Wire GroupOrchestrationService + ChannelGatewayService into Tool Registry

Why: Lines 503-504 of tool-registry.service.ts set groupOrchestration: undefined and channelGateway: undefined. This makes manage_group and send_message tools always return errors. Fixing this unlocks the entire messaging tool system.

Files:

In APIs/backend-vitto-api/src/app/tools/tool-registry.service.ts, add after line 32 (after the CredentialBridgeService import):

import { GroupOrchestrationService } from '../connectivity/group-orchestration.service';
import { ChannelGatewayService } from '../connectivity/channel-gateway.service';

Find the constructor (search for constructor() and add two new parameters at the end, before the closing ):

@Optional() private readonly groupOrchestrationService?: GroupOrchestrationService,
@Optional() private readonly channelGatewayService?: ChannelGatewayService,

@Optional() is already imported from @nestjs/common (line 9). These services come from ConnectivityModule which EngineModule already imports (line 114 of engine.module.ts).

Replace lines 503-504:

// BEFORE (line 503-504):
groupOrchestration: undefined,
channelGateway: undefined,

// AFTER:
groupOrchestration: this.groupOrchestrationService
  ? {
      createGroup: (opts: Record<string, unknown>) => this.groupOrchestrationService!.createGroup(opts as any),
      postUpdate: (groupId: string, message: string, opts?: { pinnable?: boolean }) => this.groupOrchestrationService!.postUpdate(groupId, message, opts),
      pinMessage: (groupId: string, messageId: string) => this.groupOrchestrationService!.pinMessage(groupId, messageId),
      archiveGroup: (groupId: string) => this.groupOrchestrationService!.archiveGroup(groupId),
      listGroups: (spaceId: string, opts?: { purpose?: string }) => this.groupOrchestrationService!.listGroups(spaceId, opts),
      addParticipant: (groupId: string, participant: Record<string, unknown>) => this.groupOrchestrationService!.addParticipant(groupId, participant as any),
      removeParticipant: (groupId: string, externalId: string) => this.groupOrchestrationService!.removeParticipant(groupId, externalId),
    }
  : undefined,
channelGateway: this.channelGatewayService
  ? {
      sendResponse: (opts: { channelType: string; channelId: string; text: string; metadata?: Record<string, unknown> }) =>
        this.channelGatewayService!.sendResponse(opts as any),
    }
  : undefined,

This follows the exact pattern already used for whistleOrchestrator (lines 505-510) — wrapping service methods in a typed object to match the ToolPluginContext interface at tool-plugin.types.ts:84-96.

Run: npx nx build backend-vitto-api --configuration=development 2>&1 | tail -20 Expected: Build succeeds. No type errors.

Start backend-vitto-api and check logs for:

These already log during startup — confirming the tools register. The difference is now ctx.groupOrchestration and ctx.channelGateway will be defined instead of undefined.


Task 2: Telegram Channel Adapter

Why: Telegram responses are text-only. Users can't interact with buttons or make selections. This adapter converts RichMessage types to Telegram inline keyboards.

Files:

Create APIs/backend-vitto-api/src/app/engine/adapters/telegram-channel.adapter.ts:

/**
 * S250: Telegram Channel Adapter
 * Converts RichMessage types to Telegram inline keyboards.
 * Uses Telegram Bot API sendMessage with reply_markup.
 * On failure, returns fallbackText for plain text send.
 */

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
  RichMessage,
  QuickReplyMessage,
  ListPickerMessage,
  CardMessage,
  CTAMessage,
} from '../rich-messages/rich-message.types';
import {
  ChannelAdapter,
  ChannelAdapterContext,
  ChannelAdapterResult,
} from './channel-adapter.interface';

@Injectable()
export class TelegramChannelAdapter implements ChannelAdapter {
  readonly channelType = 'telegram';
  private readonly logger = new Logger(TelegramChannelAdapter.name);

  constructor(private readonly config: ConfigService) {}

  supports(message: RichMessage): boolean {
    return ['quick_reply', 'list_picker', 'card', 'cta'].includes(message.type);
  }

  async render(message: RichMessage, context: ChannelAdapterContext): Promise<ChannelAdapterResult> {
    const token = this.config.get<string>('TELEGRAM_BOT_TOKEN');
    if (!token) {
      return { success: false, error: 'TELEGRAM_BOT_TOKEN not configured' };
    }

    try {
      let text: string;
      let inlineKeyboard: Array<Array<{ text: string; callback_data?: string; url?: string }>>;

      switch (message.type) {
        case 'quick_reply': {
          const qr = message as QuickReplyMessage;
          text = qr.text;
          inlineKeyboard = [qr.buttons.map(b => ({
            text: b.title,
            callback_data: b.id || b.title,
          }))];
          break;
        }
        case 'list_picker': {
          const lp = message as ListPickerMessage;
          text = lp.body;
          inlineKeyboard = lp.sections.flatMap(s =>
            s.items.map(item => ([{
              text: item.title,
              callback_data: item.id,
            }]))
          );
          break;
        }
        case 'card': {
          const card = message as CardMessage;
          text = `*${card.title}*${card.subtitle ? `\n${card.subtitle}` : ''}${card.body ? `\n\n${card.body}` : ''}`;
          inlineKeyboard = [card.buttons.map(b => ({
            text: b.title,
            ...(b.type === 'url' && b.url ? { url: b.url } : { callback_data: b.id || b.title }),
          }))];
          break;
        }
        case 'cta': {
          const cta = message as CTAMessage;
          text = cta.text;
          inlineKeyboard = [cta.buttons.map(b => ({
            text: b.title,
            url: b.url,
          }))];
          break;
        }
        default:
          return { success: false, error: `Unsupported type: ${message.type}` };
      }

      const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          chat_id: context.channelId,
          text,
          parse_mode: 'Markdown',
          reply_markup: { inline_keyboard: inlineKeyboard },
        }),
      });

      if (!res.ok) {
        const errText = await res.text();
        this.logger.warn(`Telegram adapter send failed: ${errText}`);
        return { success: false, error: errText };
      }

      return { success: true, rendered: { alreadySent: true } };
    } catch (err) {
      this.logger.warn(`Telegram adapter error: ${(err as Error).message}`);
      return { success: false, error: (err as Error).message };
    }
  }
}

Add import after line 82 (TeamsChannelAdapter):

import { TelegramChannelAdapter } from './adapters/telegram-channel.adapter';

Update the CHANNEL_ADAPTERS factory (line 206-210):

// BEFORE:
{
  provide: 'CHANNEL_ADAPTERS',
  useFactory: (wa: WhatsAppChannelAdapter, web: WebChatChannelAdapter, teams: TeamsChannelAdapter) => [wa, web, teams],
  inject: [WhatsAppChannelAdapter, WebChatChannelAdapter, TeamsChannelAdapter],
},

// AFTER:
{
  provide: 'CHANNEL_ADAPTERS',
  useFactory: (wa: WhatsAppChannelAdapter, web: WebChatChannelAdapter, teams: TeamsChannelAdapter, tg: TelegramChannelAdapter) => [wa, web, teams, tg],
  inject: [WhatsAppChannelAdapter, WebChatChannelAdapter, TeamsChannelAdapter, TelegramChannelAdapter],
},

Also add TelegramChannelAdapter to the providers array (after line 189 where WhatsAppChannelAdapter is):

TelegramChannelAdapter,

In APIs/backend-vitto-api/src/app/connectivity/channel-gateway.service.ts, find handleTelegramWebhook() (line 893). Add callback_query handling at the start of the method:

async handleTelegramWebhook(
  body: Record<string, unknown>,
): Promise<{ message?: ChannelMessage; error?: string }> {
  // S250: Handle inline keyboard button presses
  if (body.callback_query) {
    const query = body.callback_query as Record<string, unknown>;
    const message = query.message as Record<string, unknown>;
    const from = query.from as Record<string, unknown>;
    const chatId = (message?.chat as Record<string, unknown>)?.id;

    // Acknowledge the callback (removes loading spinner on client)
    const token = this.config.get<string>('TELEGRAM_BOT_TOKEN');
    if (token) {
      fetch(`https://api.telegram.org/bot${token}/answerCallbackQuery`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ callback_query_id: query.id }),
      }).catch(() => {});
    }

    return {
      message: {
        channelType: 'telegram',
        channelId: String(chatId),
        userId: String(from?.id),
        text: query.data as string,
        metadata: {
          callbackQueryId: query.id,
          messageId: (message as Record<string, unknown>)?.message_id,
        },
        timestamp: new Date(),
      } as ChannelMessage,
    };
  }

  // ... existing message handling below ...

Run: npx nx build backend-vitto-api --configuration=development 2>&1 | tail -20 Expected: Build succeeds.


Task 3: Slack Channel Adapter + Interaction Handler

Why: Slack responses are text-only. Buttons and interactive components aren't rendered. Slack also needs a separate interaction endpoint for button callback processing.

Files:

Create APIs/backend-vitto-api/src/app/engine/adapters/slack-channel.adapter.ts:

/**
 * S250: Slack Channel Adapter
 * Converts RichMessage types to Slack Block Kit format.
 * Returns rendered text with blocks — caller (ChannelGatewayService) sends via Slack API.
 *
 * Unlike WhatsApp adapter which sends directly, Slack adapter returns
 * Block Kit JSON that gets included in the sendSlackMessage() payload.
 * This follows the same pattern as TeamsChannelAdapter (returns Adaptive Cards).
 */

import { Injectable, Logger } from '@nestjs/common';
import {
  RichMessage,
  QuickReplyMessage,
  ListPickerMessage,
  CardMessage,
  CTAMessage,
} from '../rich-messages/rich-message.types';
import {
  ChannelAdapter,
  ChannelAdapterContext,
  ChannelAdapterResult,
} from './channel-adapter.interface';

@Injectable()
export class SlackChannelAdapter implements ChannelAdapter {
  readonly channelType = 'slack';
  private readonly logger = new Logger(SlackChannelAdapter.name);

  supports(message: RichMessage): boolean {
    return ['quick_reply', 'list_picker', 'card', 'cta'].includes(message.type);
  }

  async render(message: RichMessage, _context: ChannelAdapterContext): Promise<ChannelAdapterResult> {
    try {
      let blocks: Record<string, unknown>[];

      switch (message.type) {
        case 'quick_reply': {
          const qr = message as QuickReplyMessage;
          blocks = [
            { type: 'section', text: { type: 'mrkdwn', text: qr.text } },
            {
              type: 'actions',
              elements: qr.buttons.map(b => ({
                type: 'button',
                text: { type: 'plain_text', text: b.title },
                action_id: `vitto_qr_${b.id || b.title}`,
                value: b.id || b.title,
              })),
            },
          ];
          break;
        }
        case 'list_picker': {
          const lp = message as ListPickerMessage;
          const options = lp.sections.flatMap(s =>
            s.items.map(item => ({
              text: { type: 'plain_text', text: item.title },
              description: item.description ? { type: 'plain_text', text: item.description } : undefined,
              value: item.id,
            }))
          );
          blocks = [
            { type: 'section', text: { type: 'mrkdwn', text: lp.body } },
            {
              type: 'actions',
              elements: [{
                type: 'static_select',
                placeholder: { type: 'plain_text', text: lp.buttonText },
                action_id: 'vitto_list_select',
                options,
              }],
            },
          ];
          break;
        }
        case 'card': {
          const card = message as CardMessage;
          const textParts = [`*${card.title}*`];
          if (card.subtitle) textParts.push(card.subtitle);
          if (card.body) textParts.push(card.body);
          blocks = [
            { type: 'section', text: { type: 'mrkdwn', text: textParts.join('\n') } },
          ];
          if (card.mediaUrl) {
            blocks.push({ type: 'image', image_url: card.mediaUrl, alt_text: card.title });
          }
          if (card.buttons.length > 0) {
            blocks.push({
              type: 'actions',
              elements: card.buttons.map(b => ({
                type: 'button',
                text: { type: 'plain_text', text: b.title },
                ...(b.type === 'url' && b.url ? { url: b.url } : { value: b.id || b.title }),
                action_id: `vitto_card_${b.id || b.title}`,
              })),
            });
          }
          break;
        }
        case 'cta': {
          const cta = message as CTAMessage;
          blocks = [
            { type: 'section', text: { type: 'mrkdwn', text: cta.text } },
            {
              type: 'actions',
              elements: cta.buttons.map(b => ({
                type: 'button',
                text: { type: 'plain_text', text: b.title },
                url: b.url,
                action_id: `vitto_cta_${b.title}`,
              })),
            },
          ];
          break;
        }
        default:
          return { success: false, error: `Unsupported: ${message.type}` };
      }

      // Return blocks as rendered text — ChannelGatewayService.sendSlackMessage()
      // will detect this and include blocks in the Slack API payload.
      return {
        success: true,
        rendered: {
          text: JSON.stringify({ blocks }),
          alreadySent: false, // Gateway still sends — we just provide the payload
        },
      };
    } catch (err) {
      this.logger.warn(`Slack adapter error: ${(err as Error).message}`);
      return { success: false, error: (err as Error).message };
    }
  }
}

Add import:

import { SlackChannelAdapter } from './adapters/slack-channel.adapter';

Update CHANNEL_ADAPTERS factory (add slack to the existing update from Task 2):

{
  provide: 'CHANNEL_ADAPTERS',
  useFactory: (wa: WhatsAppChannelAdapter, web: WebChatChannelAdapter, teams: TeamsChannelAdapter, tg: TelegramChannelAdapter, slack: SlackChannelAdapter) =>
    [wa, web, teams, tg, slack],
  inject: [WhatsAppChannelAdapter, WebChatChannelAdapter, TeamsChannelAdapter, TelegramChannelAdapter, SlackChannelAdapter],
},

Add SlackChannelAdapter to providers array.

In APIs/backend-vitto-api/src/app/engine/vitto.controller.ts, add after the slackEvents method (after line ~1996):

/**
 * S250: Slack interactivity receiver.
 * Handles button clicks, select menu selections, and other component interactions.
 *
 * IMPORTANT: Slack sends interaction payloads as application/x-www-form-urlencoded
 * with a `payload` field containing a JSON string. NestJS default JSON body parser
 * won't parse this. We use @Body('payload') to extract the field directly.
 * If urlencoded parsing is not enabled, add `app.use(urlencoded({ extended: true }))`
 * to main.ts bootstrap.
 *
 * Setup: In Slack App → Interactivity & Shortcuts → Request URL:
 *   https://<domain>/vitto-api/engine/integrations/slack/interactions
 */
@Public()
@Post('integrations/slack/interactions')
async slackInteraction(@Body('payload') payloadStr: string) {
  if (!payloadStr) return { ok: true };

  let payload: Record<string, unknown>;
  try {
    payload = JSON.parse(payloadStr);
  } catch {
    return { ok: true }; // Malformed — acknowledge silently
  }

  const actions = (payload.actions || []) as Array<Record<string, unknown>>;
  if (actions.length === 0) return { ok: true };

  const action = actions[0];
  const selectedOption = action.selected_option as Record<string, unknown> | undefined;
  const value = ((action.value as string) || selectedOption?.value || '') as string;
  const channel = (payload.channel as Record<string, unknown>)?.id as string;
  const user = (payload.user as Record<string, unknown>)?.id as string;
  const threadTs = (payload.message as Record<string, unknown>)?.ts as string;

  if (value && channel && user) {
    await this.engine.submitTask({
      summary: value,
      type: 'channel-message',
      priority: 'normal',
      context: {
        channelType: 'slack',
        channelId: channel,
        userId: user,
        replyTo: 'slack',
        replyChannelId: channel,
        replyMetadata: threadTs ? { threadTs } : {},
      },
    });
  }

  return { ok: true }; // Slack requires 200 OK within 3 seconds
}

Run: npx nx build backend-vitto-api --configuration=development 2>&1 | tail -20 Expected: Build succeeds.


Task 4: Discord Channel Adapter + Component Handling

Why: Discord only renders embeds. No buttons or select menus. The existing handleDiscordEvent() only handles PING (type 1) — component interactions (type 3) are ignored.

Files:

Create APIs/backend-vitto-api/src/app/engine/adapters/discord-channel.adapter.ts:

/**
 * S250: Discord Channel Adapter
 * Converts RichMessage types to Discord components (buttons, select menus).
 * Uses Discord API v10 component structure.
 */

import { Injectable, Logger } from '@nestjs/common';
import {
  RichMessage,
  QuickReplyMessage,
  ListPickerMessage,
  CardMessage,
  CTAMessage,
} from '../rich-messages/rich-message.types';
import {
  ChannelAdapter,
  ChannelAdapterContext,
  ChannelAdapterResult,
} from './channel-adapter.interface';

// Discord component types
const ACTION_ROW = 1;
const BUTTON = 2;
const STRING_SELECT = 3;
const BUTTON_PRIMARY = 1;
const BUTTON_LINK = 5;

@Injectable()
export class DiscordChannelAdapter implements ChannelAdapter {
  readonly channelType = 'discord';
  private readonly logger = new Logger(DiscordChannelAdapter.name);

  supports(message: RichMessage): boolean {
    return ['quick_reply', 'list_picker', 'card', 'cta'].includes(message.type);
  }

  async render(message: RichMessage, _context: ChannelAdapterContext): Promise<ChannelAdapterResult> {
    try {
      let content: string;
      let components: Record<string, unknown>[];

      switch (message.type) {
        case 'quick_reply': {
          const qr = message as QuickReplyMessage;
          content = qr.text;
          components = [{
            type: ACTION_ROW,
            components: qr.buttons.map(b => ({
              type: BUTTON,
              style: BUTTON_PRIMARY,
              label: b.title.substring(0, 80),
              custom_id: `vitto_qr:${b.id || b.title}`,
            })),
          }];
          break;
        }
        case 'list_picker': {
          const lp = message as ListPickerMessage;
          content = lp.body;
          const options = lp.sections.flatMap(s =>
            s.items.map(item => ({
              label: item.title.substring(0, 100),
              value: item.id,
              description: item.description?.substring(0, 100),
            }))
          ).slice(0, 25); // Discord max 25 options
          components = [{
            type: ACTION_ROW,
            components: [{
              type: STRING_SELECT,
              custom_id: 'vitto_list_select',
              placeholder: lp.buttonText,
              options,
            }],
          }];
          break;
        }
        case 'card': {
          const card = message as CardMessage;
          const parts = [`**${card.title}**`];
          if (card.subtitle) parts.push(card.subtitle);
          if (card.body) parts.push(card.body);
          content = parts.join('\n');
          components = [{
            type: ACTION_ROW,
            components: card.buttons.map(b => ({
              type: BUTTON,
              ...(b.type === 'url' && b.url
                ? { style: BUTTON_LINK, url: b.url }
                : { style: BUTTON_PRIMARY, custom_id: `vitto_card:${b.id || b.title}` }),
              label: b.title.substring(0, 80),
            })),
          }];
          break;
        }
        case 'cta': {
          const cta = message as CTAMessage;
          content = cta.text;
          components = [{
            type: ACTION_ROW,
            components: cta.buttons.map(b => ({
              type: BUTTON,
              style: BUTTON_LINK,
              label: b.title.substring(0, 80),
              url: b.url,
            })),
          }];
          break;
        }
        default:
          return { success: false, error: `Unsupported: ${message.type}` };
      }

      // Return components as JSON — gateway.sendDiscordMessage() includes them
      return {
        success: true,
        rendered: {
          text: JSON.stringify({ content, components }),
          alreadySent: false,
        },
      };
    } catch (err) {
      this.logger.warn(`Discord adapter error: ${(err as Error).message}`);
      return { success: false, error: (err as Error).message };
    }
  }
}

Add import:

import { DiscordChannelAdapter } from './adapters/discord-channel.adapter';

Update CHANNEL_ADAPTERS factory (final version with all 6 adapters):

{
  provide: 'CHANNEL_ADAPTERS',
  useFactory: (
    wa: WhatsAppChannelAdapter, web: WebChatChannelAdapter, teams: TeamsChannelAdapter,
    tg: TelegramChannelAdapter, slack: SlackChannelAdapter, discord: DiscordChannelAdapter,
  ) => [wa, web, teams, tg, slack, discord],
  inject: [
    WhatsAppChannelAdapter, WebChatChannelAdapter, TeamsChannelAdapter,
    TelegramChannelAdapter, SlackChannelAdapter, DiscordChannelAdapter,
  ],
},

Add DiscordChannelAdapter to providers array.

In APIs/backend-vitto-api/src/app/connectivity/channel-gateway.service.ts, find handleDiscordEvent() (line 872). Add component interaction handling:

async handleDiscordEvent(
  body: Record<string, unknown>,
): Promise<{ message?: ChannelMessage; error?: string; pong?: boolean }> {
  // Discord PING verification
  if (body.type === 1) return { pong: true };

  // S250: Component interactions (button clicks, select menus)
  // Discord requires an immediate response within 3 seconds.
  // We send a DEFERRED_UPDATE_MESSAGE (type 6) to acknowledge, then process async.
  if (body.type === 3) {
    const data = body.data as Record<string, unknown>;
    const user = body.member
      ? ((body.member as Record<string, unknown>).user as Record<string, unknown>)
      : body.user as Record<string, unknown>;
    const channelId = body.channel_id as string;

    // Extract value from button custom_id or select menu values
    let value: string;
    if (data.component_type === 3) {
      // Select menu — values array
      value = ((data.values as string[]) || [])[0] || '';
    } else {
      // Button — custom_id format is "vitto_qr:value"
      const customId = data.custom_id as string;
      value = customId.includes(':') ? customId.split(':').slice(1).join(':') : customId;
    }

    return {
      // The controller must return { type: 6 } (DEFERRED_UPDATE_MESSAGE) to Discord
      // to acknowledge the interaction. The message is then processed async via submitTask.
      discordInteractionResponse: { type: 6 },
      message: {
        channelType: 'discord',
        channelId,
        userId: (user?.id as string) || 'unknown',
        text: value,
        metadata: { interactionId: body.id, interactionToken: body.token },
        timestamp: new Date(),
      } as ChannelMessage,
    };
  }

  // Existing message handling (type 2 = APPLICATION_COMMAND, etc.)
  // ... keep existing code below ...

In vitto.controller.ts, update the discordWebhook method (line 2148) to handle the new discordInteractionResponse field. Add this block after the PING check (after line 2154), before the error check:

// S250: Discord component interaction — return deferred ack, process async
const discordResult = result as any;
if (discordResult.discordInteractionResponse) {
  if (result.message) {
    // Fire-and-forget — process the interaction async while Discord gets its ack
    this.engine.submitTask({
      summary: result.message.text,
      type: 'channel-message',
      priority: 'normal',
      context: {
        channelType: 'discord',
        channelId: result.message.channelId,
        userId: result.message.userId,
        metadata: result.message.metadata,
        replyTo: 'discord',
        replyChannelId: result.message.channelId,
      },
    }).catch(err => this.logger.error(`Discord interaction task failed: ${(err as Error).message}`));
  }
  return discordResult.discordInteractionResponse; // { type: 6 } = DEFERRED_UPDATE_MESSAGE
}

Discord requires a response within 3 seconds. Type 6 (DEFERRED_UPDATE_MESSAGE) tells Discord "I got it, I'll update later." Vitto processes async via submitTask() and replies through the normal routeTaskResponse → sendDiscordMessage path.

Run: npx nx build backend-vitto-api --configuration=development 2>&1 | tail -20 Expected: Build succeeds.


Task 5: Update ChannelGatewayService to Use Adapter-Rendered Payloads

Why: The new Slack and Discord adapters return rendered.text as JSON payloads (Block Kit blocks / Discord components). The gateway's sendSlackMessage() and sendDiscordMessage() currently call formatForSlack()/formatForDiscord() on ALL text — this would double-format adapter payloads. We need to skip the format step when adapter payloads are detected.

Design note: The Telegram adapter sends directly via fetch() and returns { alreadySent: true }, matching the WhatsApp adapter pattern. The Slack and Discord adapters return payloads for the gateway to send with { alreadySent: false }, matching the Teams adapter pattern. Both patterns are valid and used in the existing codebase.

Files:

Replace the full sendSlackMessage method (lines 506-530) with:

private async sendSlackMessage(response: ChannelResponse): Promise<boolean> {
  const token = this.config.get<string>('SLACK_BOT_TOKEN');
  if (!token) { this.logger.warn('SLACK_BOT_TOKEN not configured'); return false; }

  // S250: Detect adapter-rendered Block Kit payload (from SlackChannelAdapter)
  // When adapter has already composed blocks, skip formatForSlack() to avoid double-formatting.
  let payload: Record<string, unknown>;

  let adapterBlocks: unknown[] | undefined;
  try {
    const parsed = JSON.parse(response.text);
    if (parsed.blocks) adapterBlocks = parsed.blocks;
  } catch {
    // Not JSON — normal text message
  }

  if (adapterBlocks) {
    payload = {
      channel: response.channelId,
      blocks: adapterBlocks,
      text: 'Vitto sent a message', // Slack requires text as fallback for notifications
      unfurl_links: false,
      ...(response.metadata?.threadTs && { thread_ts: response.metadata.threadTs }),
    };
  } else {
    // Existing path: format plain text into Slack blocks
    const formatted = formatForSlack(response.text, {
      taskStatus: response.metadata?.taskStatus as string,
      durationMs: response.metadata?.durationMs as number,
      toolCalls: response.metadata?.toolCalls as Array<{ toolName: string; durationMs: number; success: boolean }>,
      model: response.metadata?.model as string,
    });
    payload = {
      channel: response.channelId,
      text: formatted.text,
      blocks: formatted.blocks,
      unfurl_links: false,
      ...(response.metadata?.threadTs && { thread_ts: response.metadata.threadTs }),
    };
  }

  const res = await fetch('https://slack.com/api/chat.postMessage', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
    body: JSON.stringify(payload),
  });
  const data = await res.json() as { ok: boolean };
  return data.ok;
}

Replace the full sendDiscordMessage method (lines 536-553) with:

private async sendDiscordMessage(response: ChannelResponse): Promise<boolean> {
  const token = this.config.get<string>('DISCORD_BOT_TOKEN');
  if (!token) { this.logger.warn('DISCORD_BOT_TOKEN not configured'); return false; }

  // S250: Detect adapter-rendered components payload (from DiscordChannelAdapter)
  // When adapter has already composed components, skip formatForDiscord() to avoid double-formatting.
  let payload: Record<string, unknown>;

  let adapterPayload: { content?: string; components?: unknown[] } | undefined;
  try {
    const parsed = JSON.parse(response.text);
    if (parsed.components) adapterPayload = parsed;
  } catch {
    // Not JSON — normal text message
  }

  if (adapterPayload) {
    payload = {
      content: adapterPayload.content || '',
      components: adapterPayload.components,
    };
  } else {
    // Existing path: format plain text into Discord embed
    payload = formatForDiscord(response.text, {
      taskStatus: response.metadata?.taskStatus as string,
      durationMs: response.metadata?.durationMs as number,
      toolCalls: response.metadata?.toolCalls as Array<{ toolName: string; durationMs: number; success: boolean }>,
      model: response.metadata?.model as string,
    });
  }

  const res = await fetch(`https://discord.com/api/v10/channels/${response.channelId}/messages`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Authorization': `Bot ${token}` },
    body: JSON.stringify(payload),
  });
  return res.ok;
}

Run: npx nx build backend-vitto-api --configuration=development 2>&1 | tail -20 Expected: Build succeeds.


Task 6: Verify Full Pipeline End-to-End

Why: Tasks 1-5 make code changes. This task verifies the flows work.

Files: None — testing only.

Run: yarn serve:backend-vitto-api Verify in logs:

Open Vitto web chat and send: "List all groups in my space" Expected: Vitto calls manage_group with action: 'list' — should NOT return "GroupOrchestrationService not available". May return empty array (no groups yet), which is correct.

Open Vitto web chat and send: "Send a test message to Slack channel C12345 saying hello" Expected: Vitto calls send_message — should NOT return "ChannelGatewayService not available". May fail if Slack not configured, but the error should be about Slack config, not about the service being undefined.


Implementation Notes

What's NOT in this plan (deferred)

  1. Frontend group management UI (Layer 3 from spec) — separate plan, depends on Layer 1+2 being complete first.
  2. WAHA personal WhatsApp — standalone spec, paused until SIM card acquired.
  3. Credential configuration (Layer 4 from spec) — manual setup per channel, documented in spec.
  4. sendSlackMessage/sendDiscordMessage full refactor — Task 5 adds detection logic inline. A cleaner approach would extract adapter rendering into the ChannelProcessingService pipeline. Deferred to avoid scope creep.

Risks

Deliberate omissions (not bugs)