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:
Modify:
APIs/backend-vitto-api/src/app/tools/tool-registry.service.ts:1-34(imports) and:498-510(context)Step 1: Add imports for the two services
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';
- Step 2: Add
@Optional()constructor parameters
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).
- Step 3: Wire the context object
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.
- Step 4: Verify build compiles
Run: npx nx build backend-vitto-api --configuration=development 2>&1 | tail -20
Expected: Build succeeds. No type errors.
- Step 5: Verify tools register
Start backend-vitto-api and check logs for:
Registering tool: manage_groupRegistering tool: send_message
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.tsModify:
APIs/backend-vitto-api/src/app/engine/engine.module.ts:80,206-210(import + factory)Modify:
APIs/backend-vitto-api/src/app/connectivity/channel-gateway.service.ts(add keyboard support tosendTelegramMessage)Step 1: Create the Telegram adapter
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 };
}
}
}
- Step 2: Register in engine.module.ts
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,
- Step 3: Add callback_query handling to ChannelGatewayService
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 ...
- Step 4: Verify build compiles
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.tsModify:
APIs/backend-vitto-api/src/app/engine/engine.module.ts(import + factory)Modify:
APIs/backend-vitto-api/src/app/engine/vitto.controller.ts(add interaction endpoint)Step 1: Create the Slack adapter
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 };
}
}
}
- Step 2: Register in engine.module.ts
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.
- Step 3: Add Slack interaction endpoint
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
}
- Step 4: Verify build compiles
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.tsModify:
APIs/backend-vitto-api/src/app/engine/engine.module.ts(import + factory)Modify:
APIs/backend-vitto-api/src/app/connectivity/channel-gateway.service.ts(handle interaction type 3 inhandleDiscordEvent)Step 1: Create the Discord adapter
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 };
}
}
}
- Step 2: Register in engine.module.ts
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.
- Step 3: Handle component interactions in handleDiscordEvent
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 ...
- Step 3b: Update Discord controller to handle interaction responses
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.
- Step 4: Verify build compiles
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:
Modify:
APIs/backend-vitto-api/src/app/connectivity/channel-gateway.service.ts:506-553Step 1: Replace sendSlackMessage with adapter-aware version
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;
}
- Step 2: Replace sendDiscordMessage with adapter-aware version
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;
}
- Step 3: Verify build compiles
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.
- Step 1: Start backend-vitto-api
Run: yarn serve:backend-vitto-api
Verify in logs:
Registering adapter for channel: whatsappRegistering adapter for channel: webRegistering adapter for channel: teamsRegistering adapter for channel: telegram(NEW)Registering adapter for channel: slack(NEW)Registering adapter for channel: discord(NEW)Registering tool: manage_groupRegistering tool: send_messageStep 2: Test manage_group tool via web chat
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.
- Step 3: Test send_message tool via web chat
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)
- Frontend group management UI (Layer 3 from spec) — separate plan, depends on Layer 1+2 being complete first.
- WAHA personal WhatsApp — standalone spec, paused until SIM card acquired.
- Credential configuration (Layer 4 from spec) — manual setup per channel, documented in spec.
- 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
- Slack interaction endpoint requires Slack App configuration (Interactivity URL). Without it, buttons render but clicking them does nothing. This is a config step, not a code issue.
- Discord interaction response format — Discord expects specific response types (CHANNEL_MESSAGE_WITH_SOURCE = 4, DEFERRED = 5). The current handler returns via
engine.submitTask()which is async. Discord may timeout if Vitto takes >3 seconds. If this becomes an issue, send a deferred response first, then update later. Not implementing now — only needed if latency becomes a problem. - Adapter rendering in ChannelProcessingService — the
composeAndRenderRichMessage()method at line 419-429 calls the adapter registry. This already works. The new adapters will be called automatically when the registry finds them. No changes needed inChannelProcessingService.
Deliberate omissions (not bugs)
carousel,media,locationrich message types are not handled by the new adapters. Thesupports()method returnsfalsefor these, so the adapter registry falls back tofallbackText(plain text). These can be added later without any architectural changes.TelegramGroupServiceextension (answerCallbackQuery,sendMessageWithKeyboard) — the spec suggested adding these to the shared library. The plan uses inlinefetch()instead, which is simpler and avoids touching a shared library for a one-line API call. Can be refactored later.- Telegram adapter sends directly (returns
alreadySent: true, like WhatsApp adapter), while Slack/Discord adapters return payloads for the gateway (like Teams adapter). Both patterns exist in the codebase. The choice per adapter depends on whether the gateway's existingsend*Message()method already supports the rich format — Telegram's doesn't (text-only), so the adapter sends directly. - No unit tests for the new adapters in this plan. Adapter logic is straightforward type mapping. Integration testing (Task 6) validates the full pipeline. Unit tests can follow.
- Slack urlencoded body parsing — the interaction endpoint uses
@Body('payload')to extract the form field. Verify that NestJS urlencoded middleware is enabled inmain.ts(app.use(urlencoded({ extended: true }))). If not, add it.