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
74 changes: 74 additions & 0 deletions apps/api/src/handlers/discord/__tests__/index.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 53 additions & 1 deletion apps/api/src/handlers/discord/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import {
setLatestInboundMessageId,
} from '@roomote/communication/messages';
import { reactionEmojiMatches } from '@roomote/communication/reaction-emoji';
import { activateTaskGoal } from '@roomote/communication/task-goal';
import {
getTaskGoalActivationMessage,
parseGoalCommand,
withTaskGoalContext,
} from '@roomote/communication/task-goal-command';
import { getTaskUrl } from '@roomote/cloud-agents/server';
import {
MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE,
Expand Down Expand Up @@ -154,6 +160,7 @@ const DISCORD_HELP_MESSAGE = [
'',
'**Available commands**',
'`/new request:<request>` — start a fresh task.',
'`/goal objective:<objective>` — keep the active task working toward an objective.',
'`/link code:<code>` — link this Discord account in a DM with me.',
'`/help` — show this message.',
'',
Expand Down Expand Up @@ -508,7 +515,7 @@ 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) {
Expand All @@ -521,6 +528,16 @@ async function processDiscordGatewayEvent(
});
return { ok: true, started: false, reason: 'missing_request' };
}
if (command?.name === 'goal' && !command.objective) {
await replyToDiscordEvent({
provider: resolved.provider,
applicationId: resolved.applicationId,
channel,
interaction: interactionReplyContext(event),
text: 'Add what Roomote should keep working toward in the `objective` field.',
});
return { ok: true, queued: false, reason: 'missing_objective' };
}

const senderUserId = await findDiscordMappedUserId(sender.id);
const conversation = {
Expand Down Expand Up @@ -797,6 +814,29 @@ async function processDiscordGatewayEvent(
senderUserId,
});

const goalCommand = parseGoalCommand(queuedMessage.text);
if (goalCommand) {
const result = await activateTaskGoal({
taskId: activeRun.taskId,
objective: goalCommand.objective,
deliver: async (goal) =>
queueCommunicationMessageOnce(
'discord',
activeRun.id,
withTaskGoalContext(queuedMessage, goal),
),
});
await replyToDiscordEvent({
provider: resolved.provider,
applicationId: resolved.applicationId,
channel,
...(interaction ? { interaction: interactionReplyContext(event) } : {}),
...(message?.id ? { replyToMessageId: message.id } : {}),
text: getTaskGoalActivationMessage(result),
});
return { ok: true, queued: result.success, runId: activeRun.id };
}

if (message && queuedMessage) {
const handledRequestUserInput =
await tryHandleDiscordRequestUserInputMessage({
Expand Down Expand Up @@ -906,6 +946,18 @@ async function processDiscordGatewayEvent(
return { ok: true, queued: true, runId: activeRun.id };
}

if (parseGoalCommand(queuedMessage.text)) {
await replyToDiscordEvent({
provider: resolved.provider,
applicationId: resolved.applicationId,
channel,
...(interaction ? { interaction: interactionReplyContext(event) } : {}),
...(message?.id ? { replyToMessageId: message.id } : {}),
text: 'Use `/goal` in a conversation with an active task.',
});
return { ok: true, queued: false, reason: 'goal_requires_active_task' };
}

if (completedRun) {
// Snapshot resume also carries Slack-style thread context so the restored
// session sees earlier Discord messages, not only the resume trigger text.
Expand Down
71 changes: 57 additions & 14 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,11 @@ import {
stripLeadingSlackProductMention,
} from '@roomote/cloud-agents';
import { setTrustedRunActingUserOnSuccess } from '@roomote/db/server';
import { activateTaskGoal } from '@roomote/communication/task-goal';
import {
getTaskGoalActivationMessage,
parseGoalCommand,
} from '@roomote/communication/task-goal-command';

import { apiLogger } from '../../../logging.js';
import { retireSlackPrReviewOffersBestEffort } from '../pr-review-retire.js';
Expand Down Expand Up @@ -431,21 +436,59 @@ export async function processActiveRunMessage(
}
}

const [promptReadyThreadMessages, normalizedMessageText, trackedBotReply] =
await Promise.all([
getPromptReadyThreadMessages({
slack,
channel: event.channel,
threadTs: threadId,
botUserId,
startedMessageRunId: activeRun.id,
logContext: `active task run ${activeRun.id} in ${event.channel}:${threadId}`,
prefetchedMessages: prefetchedThreadMessages,
}),
slack.normalizeIncomingText(stripLeadingRawSlackMention(event.text)),
getLatestSlackBotReply(event.channel, threadId),
]);
const normalizedMessageText = await slack.normalizeIncomingText(
stripLeadingRawSlackMention(event.text),
);
const messageText = stripLeadingSlackProductMention(normalizedMessageText);
const goalCommand = parseGoalCommand(messageText);

if (goalCommand && activeRun.taskId) {
const result = await activateTaskGoal({
taskId: activeRun.taskId,
objective: goalCommand.objective,
deliver: async (goal) => {
await syncActingUserForInboundMessage({
logContext: 'slack.processActiveRunMessage.goal',
runId: activeRun.id,
senderUserId: userId,
});
await queueSlackMessage(activeRun.id, {
text: goal.objective,
user: event.user,
userId,
ts: event.ts,
formattedPrompt: goal.objective,
goalContext: goal,
});
},
});

await slack.postMessage({
channel: event.channel,
thread_ts: threadId,
blocks: [
{
type: 'markdown',
text: getTaskGoalActivationMessage(result),
},
],
});
deliveryTracker.track(event.ts);
return;
}

const [promptReadyThreadMessages, trackedBotReply] = await Promise.all([
getPromptReadyThreadMessages({
slack,
channel: event.channel,
threadTs: threadId,
botUserId,
startedMessageRunId: activeRun.id,
logContext: `active task run ${activeRun.id} in ${event.channel}:${threadId}`,
prefetchedMessages: prefetchedThreadMessages,
}),
getLatestSlackBotReply(event.channel, threadId),
]);
const currentMessageFiles = resolveCurrentSlackMessageFiles({
currentMessageTs: deliveryTs,
eventFiles: event.files,
Expand Down
66 changes: 66 additions & 0 deletions apps/api/src/handlers/teams/__tests__/index.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading