Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 53 additions & 17 deletions apps/api/src/handlers/discord/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
setLatestInboundMessageId,
} from '@roomote/communication/messages';
import { reactionEmojiMatches } from '@roomote/communication/reaction-emoji';
import { parseGoalCommand } from '@roomote/communication/goal-command';
import { getTaskUrl } from '@roomote/cloud-agents/server';
import {
MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE,
Expand Down Expand Up @@ -53,6 +54,7 @@ import {
findTaskBackedAutomationReportRun,
releaseCommunicationOutOfBandClaim,
resumeCommunicationTaskFromSnapshot,
activateCommunicationGoal,
} from '@roomote/sdk/server/communication';
import { tryHandleDiscordRequestUserInputMessage } from './request-user-input.js';
import { retireDiscordPrReviewOffersBestEffort } from './pr-review-action.js';
Expand Down Expand Up @@ -154,6 +156,7 @@ const DISCORD_HELP_MESSAGE = [
'',
'**Available commands**',
'`/new request:<request>` — start a fresh task.',
'`/goal request:<objective>` — keep working toward an objective across multiple turns.',
'`/link code:<code>` — link this Discord account in a DM with me.',
'`/help` — show this message.',
'',
Expand Down Expand Up @@ -508,16 +511,22 @@ async function processDiscordGatewayEvent(
return { ok: true, linked: true };
}

if (command && command.name !== 'new') {
if (command && command.name !== 'new' && command.name !== 'goal') {
return { ok: true, ignored: 'unsupported_command' };
}
if (command?.name === 'new' && !command.request) {
if (
(command?.name === 'new' || command?.name === 'goal') &&
!command.request
) {
await replyToDiscordEvent({
provider: resolved.provider,
applicationId: resolved.applicationId,
channel,
interaction: interactionReplyContext(event),
text: 'Add what you want Roomote to do in the `request` field.',
text:
command.name === 'goal'
? 'Add the objective Roomote should complete in the `request` field.'
: 'Add what you want Roomote to do in the `request` field.',
});
return { ok: true, started: false, reason: 'missing_request' };
}
Expand All @@ -531,6 +540,10 @@ async function processDiscordGatewayEvent(
: {}),
};
const forceNewTask = command?.name === 'new';
const goalCommand =
command?.name === 'goal' && command.request
? parseGoalCommand(`/goal ${command.request}`)
: null;
const repliedToAutomationReport =
!forceNewTask && message?.message_reference?.message_id
? await findTaskBackedAutomationReportRun({
Expand Down Expand Up @@ -699,17 +712,27 @@ async function processDiscordGatewayEvent(
for (const warning of processedAttachments.warnings) {
apiLogger.warn(`[discord] Attachment warning: ${warning}`);
}
const queuedMessage = discordEventToQueuedCommunicationMessage(event, {
botUserId: resolved.botUserId,
userId: senderUserId,
isTaskThread: isRoomoteThread,
parentChannelId: channel.parentChannelId,
attachmentImages: processedAttachments.images,
attachmentText: processedAttachments.attachmentTexts,
});
if (!queuedMessage) {
const normalizedQueuedMessage = discordEventToQueuedCommunicationMessage(
event,
{
botUserId: resolved.botUserId,
userId: senderUserId,
isTaskThread: isRoomoteThread,
parentChannelId: channel.parentChannelId,
attachmentImages: processedAttachments.images,
attachmentText: processedAttachments.attachmentTexts,
},
);
if (!normalizedQueuedMessage) {
return { ok: true, ignored: 'empty_task_entry' };
}
const queuedMessage = goalCommand?.goal
? {
...normalizedQueuedMessage,
text: goalCommand.objective,
goal: goalCommand.goal,
}
: normalizedQueuedMessage;

if (pendingRoutingReply) {
let routingReplyAckPinned = false;
Expand Down Expand Up @@ -868,11 +891,24 @@ async function processDiscordGatewayEvent(
message: messageForQueue,
});
try {
const queued = await queueCommunicationMessageOnce(
'discord',
activeRun.id,
messageWithOutOfBand,
);
const queueMessage = (
goalContext?: typeof messageWithOutOfBand.goalContext,
) =>
queueCommunicationMessageOnce('discord', activeRun.id, {
...messageWithOutOfBand,
...(goalContext ? { goalContext } : {}),
});
const queued = goalCommand?.goal
? await activateCommunicationGoal({
taskId: activeRun.taskId,
goal: goalCommand.goal,
deliver: async (goalContext) => {
if (!(await queueMessage(goalContext))) {
throw new Error('Discord goal command was already queued');
}
},
}).then((result) => result.success)
: await queueMessage();
// A typed reply supersedes any pending PR review offers here.
retireDiscordPrReviewOffersBestEffort({
channelId: metadata.communicationChannelId,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/handlers/discord/routing-confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ async function launchPendingDiscordRoute(input: {
provider: input.provider,
launchOwnerUserId: input.pending.launchOwnerUserId,
queuedMessage: input.pending.queuedMessage,
goal: input.pending.queuedMessage.goal,
metadata: input.pending.metadata,
channel: input.pending.channel,
workspace,
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/handlers/discord/task-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ALL_REPOSITORIES,
TaskPayloadKind,
type QueuedCommunicationMessage,
type TaskGoalInput,
type TaskInitiator,
type TaskSpec,
} from '@roomote/types';
Expand Down Expand Up @@ -361,6 +362,7 @@ export async function launchDiscordTask(input: {
metadata: DiscordEventCommunicationMetadata;
channel: DiscordChannelContext;
workspace: DiscordWorkspaceSelection;
goal?: TaskGoalInput;
/** `/new` in an existing task thread creates a sibling, never a second run in-place. */
forceNewThread?: boolean;
/**
Expand Down Expand Up @@ -493,6 +495,7 @@ export async function launchDiscordTask(input: {
workflow: 'standard',
surface: 'discord',
trigger: 'message',
...(input.goal ? { goal: input.goal } : {}),
},
{
// Automation initiators derive the 'automation' launch class; forcing
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/handlers/discord/task-orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ALL_REPOSITORIES,
type QueuedCommunicationMessage,
type TaskInitiator,
type TaskGoalInput,
} from '@roomote/types';

import type { DiscordEventCommunicationMetadata } from '@roomote/communication/discord-event';
Expand Down Expand Up @@ -83,6 +84,7 @@ export async function startNewDiscordTask(input: {
/** Absent only for automation-owned channel auto-start launches. */
launchOwnerUserId?: string;
queuedMessage: QueuedCommunicationMessage;
goal?: TaskGoalInput;
metadata: DiscordEventCommunicationMetadata;
channel: DiscordChannelContext;
interaction?: {
Expand Down Expand Up @@ -366,6 +368,7 @@ export async function startNewDiscordTask(input: {
channel: input.channel,
workspace,
forceNewThread: input.forceNewThread,
goal: input.goal ?? input.queuedMessage.goal,
...(kickoffMessage ? { kickoffMessage } : {}),
...(input.intakeAckPinned ? { intakeAckPinned: true } : {}),
});
Expand Down
44 changes: 40 additions & 4 deletions apps/api/src/handlers/slack/events/active-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
stripLeadingSlackProductMention,
} from '@roomote/cloud-agents';
import { setTrustedRunActingUserOnSuccess } from '@roomote/db/server';
import { parseGoalCommand } from '@roomote/communication/goal-command';
import { activateCommunicationGoal } from '@roomote/sdk/server/communication';

import { apiLogger } from '../../../logging.js';
import { retireSlackPrReviewOffersBestEffort } from '../pr-review-retire.js';
Expand Down Expand Up @@ -446,6 +448,15 @@ export async function processActiveRunMessage(
getLatestSlackBotReply(event.channel, threadId),
]);
const messageText = stripLeadingSlackProductMention(normalizedMessageText);
const goalCommand = parseGoalCommand(messageText);
if (goalCommand && !goalCommand.goal) {
await slack.postMessage({
channel: event.channel,
thread_ts: threadId,
text: 'Send an objective after the command — for example, `/goal ship the release`.',
});
return;
}
const currentMessageFiles = resolveCurrentSlackMessageFiles({
currentMessageTs: deliveryTs,
eventFiles: event.files,
Expand Down Expand Up @@ -521,15 +532,40 @@ export async function processActiveRunMessage(
runId: activeRun.id,
senderUserId: userId,
});
await queueSlackMessage(activeRun.id, {
text: messageTextWithVideoDescriptions,
const queuedGoalText = goalCommand?.goal
? goalCommand.objective
: messageTextWithVideoDescriptions;
const queuedMessage = {
text: queuedGoalText,
user: event.user,
userId,
ts: event.ts,
images: allImages.length > 0 ? allImages : undefined,
formattedPrompt,
formattedPrompt: goalCommand?.goal ? undefined : formattedPrompt,
turnPolicy,
});
};
if (goalCommand?.goal && activeRun.taskId) {
const activated = await activateCommunicationGoal({
taskId: activeRun.taskId,
goal: goalCommand.goal,
deliver: async (goalContext) => {
await queueSlackMessage(activeRun.id, {
...queuedMessage,
goalContext,
});
},
});
if (!activated.success) {
await slack.postMessage({
channel: event.channel,
thread_ts: threadId,
text: 'Goal Mode could not be enabled because another activation is already in progress. Try again in a moment.',
});
return;
}
} else {
await queueSlackMessage(activeRun.id, queuedMessage);
}
await clearLatestUserMessage(activeRun.id);
// A typed reply supersedes any pending PR review offers in the thread.
retireSlackPrReviewOffersBestEffort({
Expand Down
49 changes: 47 additions & 2 deletions apps/api/src/handlers/teams/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
teamsActivityToQueuedCommunicationMessage,
} from '@roomote/communication/teams-activity';
import { queueCommunicationMessage } from '@roomote/communication/messages';
import { parseGoalCommand } from '@roomote/communication/goal-command';
import {
buildAccountLinkPromptText,
buildAccountLinkThreadReplyText,
Expand All @@ -26,6 +27,7 @@ import {
} from '@roomote/communication/chat-messages';
import type { TeamsCommunicationProvider } from '@roomote/communication/teams-provider';
import { createTeamsCommunicationProviderFromRuntimeCredentials } from '@roomote/sdk/server';
import { activateCommunicationGoal } from '@roomote/sdk/server/communication';
import {
exchangeMicrosoftDelegatedGraphToken,
extractTeamsGraphHostedContentIds,
Expand Down Expand Up @@ -55,6 +57,7 @@ import {
TaskPayloadKind,
PRODUCT_NAME,
type QueuedCommunicationMessage,
type TaskGoalInput,
isDeploymentReadOnlyError,
populateSnapshotResumeCommunicationMetadata,
restoreSnapshotResumeVisiblePromptFields,
Expand Down Expand Up @@ -1338,6 +1341,7 @@ async function startNewTeamsTask(input: {
queuedMessage: QueuedTeamsCommunicationMessage;
metadata: TeamsActivityCommunicationMetadata;
workspaceOverride?: TeamsWorkspaceSelection;
goal?: TaskGoalInput;
}) {
const launchUserId = input.mappedUserId;
const threadHistory = await fetchTeamsThreadMessagesBestEffort({
Expand Down Expand Up @@ -1428,6 +1432,7 @@ async function startNewTeamsTask(input: {
workflow: 'standard',
surface: 'teams',
trigger: 'message',
...(input.goal ? { goal: input.goal } : {}),
},
{
launchClass: 'human',
Expand Down Expand Up @@ -1516,7 +1521,6 @@ async function resumePendingTeamsAuthToken(
conversationId: metadata.communicationChannelId,
threadId: metadata.communicationThreadId,
});

if (activeRun) {
// Trusted pre-queue actor switch; see acting-user-sync.ts.
await syncActingUserForInboundMessage({
Expand Down Expand Up @@ -1950,6 +1954,23 @@ teams.post('/', async (c) => {
conversationId: metadata.communicationChannelId,
threadId: metadata.communicationThreadId,
});
const goalCommand = parseGoalCommand(queuedMessage.text);
if (goalCommand && !goalCommand.goal) {
await postTeamsMessageBestEffort({
conversationId: metadata.communicationChannelId,
threadId: metadata.communicationThreadId,
serviceUrl: metadata.communicationServiceUrl,
text: 'Send an objective after the command — for example, `/goal ship the release`.',
});
return c.json({ ok: true, queued: false, repliedInline: true });
}
if (goalCommand?.goal) {
queuedMessage = {
...queuedMessage,
text: goalCommand.objective,
goal: goalCommand.goal,
};
}

if (!activeRun) {
if (!isTeamsTaskEntryActivity(activity)) {
Expand Down Expand Up @@ -2182,6 +2203,7 @@ teams.post('/', async (c) => {
mappedUserId,
queuedMessage,
metadata,
goal: queuedMessage.goal,
});
} catch (error) {
if (isDeploymentReadOnlyError(error)) {
Expand Down Expand Up @@ -2267,7 +2289,30 @@ teams.post('/', async (c) => {
outOfBandClaim = attached.claim;
}
try {
await queueCommunicationMessage('teams', activeRun.id, activeFollowUp);
if (goalCommand?.goal) {
const activated = await activateCommunicationGoal({
taskId: activeRun.taskId,
goal: goalCommand.goal,
deliver: async (goalContext) => {
await queueCommunicationMessage('teams', activeRun.id, {
...activeFollowUp,
goalContext,
});
},
});
if (!activated.success) {
await releaseCommunicationOutOfBandClaim(outOfBandClaim);
await postTeamsMessageBestEffort({
conversationId: metadata.communicationChannelId,
threadId: metadata.communicationThreadId,
serviceUrl: metadata.communicationServiceUrl,
text: 'Goal Mode could not be enabled because another activation is already in progress. Try again in a moment.',
});
return c.json({ ok: true, queued: false, repliedInline: true });
}
} else {
await queueCommunicationMessage('teams', activeRun.id, activeFollowUp);
}
} catch (error) {
await releaseCommunicationOutOfBandClaim(outOfBandClaim);
throw error;
Expand Down
Loading
Loading