From c682edba297faf02017464bb2ba3342a754d7571 Mon Sep 17 00:00:00 2001 From: LIU9293 Date: Tue, 24 Feb 2026 14:48:41 +0000 Subject: [PATCH] fix: unify markdown messaging and move Discord settings to modals --- package.json | 2 +- packages/core/runtime.ts | 14 +- packages/core/runtime/event-stream.ts | 3 +- packages/core/runtime/message-updates.ts | 8 +- packages/core/runtime/open-request.ts | 5 +- packages/core/runtime/pending-question.ts | 5 +- packages/core/runtime/recovery.ts | 3 +- packages/core/runtime/request-runner.ts | 2 +- packages/core/runtime/selection-reply.ts | 4 +- packages/core/runtime/session-bootstrap.ts | 8 +- packages/core/types.ts | 5 +- packages/ims/discord/client.ts | 6 +- packages/ims/discord/settings.ts | 147 +++++++++++++++++++-- packages/ims/lark/client.ts | 28 ++-- packages/ims/slack/client.ts | 13 +- 15 files changed, 179 insertions(+), 74 deletions(-) diff --git a/package.json b/package.json index 23852dc..af67c95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ode", - "version": "0.0.90", + "version": "0.0.91", "description": "Coding anywhere with your coding agents connected", "module": "packages/core/index.ts", "type": "module", diff --git a/packages/core/runtime.ts b/packages/core/runtime.ts index 929627e..374d970 100644 --- a/packages/core/runtime.ts +++ b/packages/core/runtime.ts @@ -152,7 +152,7 @@ export function createCoreRuntime(deps: RuntimeDeps) { if (finalChunks.length > 1) { if (statusFormat !== "aggressive" && !statusRateLimited) { - await runtimeDeps.im.updateMessage(channelId, statusTs, "Final result posted below in multiple messages.", false); + await runtimeDeps.im.updateMessage(channelId, statusTs, "Final result posted below in multiple messages."); } else if (statusRateLimited) { log.warn("Skipping final status update due to prior 429; posting final chunks as new messages", { channelId, @@ -163,13 +163,13 @@ export function createCoreRuntime(deps: RuntimeDeps) { } for (const chunk of finalChunks) { - await runtimeDeps.im.sendMessage(channelId, threadId, chunk, true); + await runtimeDeps.im.sendMessage(channelId, threadId, chunk); } return; } if (statusFormat === "aggressive") { - await runtimeDeps.im.sendMessage(channelId, threadId, singleChunk, true); + await runtimeDeps.im.sendMessage(channelId, threadId, singleChunk); return; } @@ -180,18 +180,18 @@ export function createCoreRuntime(deps: RuntimeDeps) { statusTs, ...(statusRateLimitError ? { error: statusRateLimitError } : {}), }); - await runtimeDeps.im.sendMessage(channelId, threadId, singleChunk, true); + await runtimeDeps.im.sendMessage(channelId, threadId, singleChunk); return; } const maxEditableMessageChars = runtimeDeps.im.maxEditableMessageChars; if (typeof maxEditableMessageChars === "number" && singleChunk.length > maxEditableMessageChars) { - await runtimeDeps.im.updateMessage(channelId, statusTs, "Final result posted below.", false); - await runtimeDeps.im.sendMessage(channelId, threadId, singleChunk, true); + await runtimeDeps.im.updateMessage(channelId, statusTs, "Final result posted below."); + await runtimeDeps.im.sendMessage(channelId, threadId, singleChunk); return; } - await runtimeDeps.im.updateMessage(channelId, statusTs, singleChunk, true); + await runtimeDeps.im.updateMessage(channelId, statusTs, singleChunk); } async function handleUserMessageInternal(context: CoreMessageContext, text: string): Promise { diff --git a/packages/core/runtime/event-stream.ts b/packages/core/runtime/event-stream.ts index 641dab0..794a8f3 100644 --- a/packages/core/runtime/event-stream.ts +++ b/packages/core/runtime/event-stream.ts @@ -169,8 +169,7 @@ export async function startEventStreamWatcher( workingPath, state: liveParsedState.get(messageKey), statusMessageFormat: resolveStatusMessageFormat(), - }), - false + }) ); if (typeof updatedStatusTs === "string" && updatedStatusTs !== request.statusMessageTs) { request.statusMessageTs = updatedStatusTs; diff --git a/packages/core/runtime/message-updates.ts b/packages/core/runtime/message-updates.ts index 6a7a4d8..19c5e64 100644 --- a/packages/core/runtime/message-updates.ts +++ b/packages/core/runtime/message-updates.ts @@ -6,7 +6,6 @@ type QueuedUpdate = { channelId: string; messageTs: string; text: string; - asMarkdown: boolean; resolve: (messageTs?: string) => void; }; @@ -44,7 +43,7 @@ export function createRateLimitedImAdapter( globalLastUpdateAt = Date.now(); try { - const maybeUpdatedTs = await im.updateMessage(item.channelId, item.messageTs, item.text, item.asMarkdown); + const maybeUpdatedTs = await im.updateMessage(item.channelId, item.messageTs, item.text); item.resolve(typeof maybeUpdatedTs === "string" ? maybeUpdatedTs : undefined); } catch (error) { if (isRateLimitError(error)) { @@ -87,8 +86,7 @@ export function createRateLimitedImAdapter( updateMessage: async ( channelId: string, messageTs: string, - text: string, - asMarkdown = true + text: string ): Promise => { for (let i = queue.length - 1; i >= 0; i--) { const queued = queue[i]; @@ -99,7 +97,7 @@ export function createRateLimitedImAdapter( } return new Promise((resolve) => { - queue.push({ channelId, messageTs, text, asMarkdown, resolve }); + queue.push({ channelId, messageTs, text, resolve }); void processQueue(); }); }, diff --git a/packages/core/runtime/open-request.ts b/packages/core/runtime/open-request.ts index b043bae..483b6bc 100644 --- a/packages/core/runtime/open-request.ts +++ b/packages/core/runtime/open-request.ts @@ -60,8 +60,7 @@ export async function runOpenRequest(params: { const initialStatusTs = await deps.im.sendMessage( context.channelId, context.replyThreadId, - `${providerLabel} is running...`, - false + `${providerLabel} is running...` ); if (!initialStatusTs) { @@ -126,7 +125,7 @@ export async function runOpenRequest(params: { statusMessageFormat: resolveStatusMessageFormat(), }); if (!request.statusFrozen) { - const updatedStatusTs = await deps.im.updateMessage(context.channelId, statusTs, statusText, false); + const updatedStatusTs = await deps.im.updateMessage(context.channelId, statusTs, statusText); if (typeof updatedStatusTs === "string" && updatedStatusTs !== statusTs) { statusTs = updatedStatusTs; request.statusMessageTs = updatedStatusTs; diff --git a/packages/core/runtime/pending-question.ts b/packages/core/runtime/pending-question.ts index dca6c07..e4b2f61 100644 --- a/packages/core/runtime/pending-question.ts +++ b/packages/core/runtime/pending-question.ts @@ -33,7 +33,7 @@ export async function handlePendingQuestionReply(params: { const trimmed = text.trim(); if (!trimmed) { - await deps.im.sendMessage(context.channelId, context.replyThreadId, "Please reply with an answer.", false); + await deps.im.sendMessage(context.channelId, context.replyThreadId, "Please reply with an answer."); return true; } @@ -54,8 +54,7 @@ export async function handlePendingQuestionReply(params: { await deps.im.sendMessage( context.channelId, context.replyThreadId, - "Failed to submit your answer. Please try again.", - false + "Failed to submit your answer. Please try again." ); return true; } diff --git a/packages/core/runtime/recovery.ts b/packages/core/runtime/recovery.ts index 6b8b12d..a6e32e3 100644 --- a/packages/core/runtime/recovery.ts +++ b/packages/core/runtime/recovery.ts @@ -33,8 +33,7 @@ export async function recoverPendingRequests( await im.updateMessage( request.channelId, request.statusMessageTs, - "_Bot restarted - please resend your message_", - false + "_Bot restarted - please resend your message_" ); clearActiveRequest(session.channelId, session.threadId); diff --git a/packages/core/runtime/request-runner.ts b/packages/core/runtime/request-runner.ts index bdca208..0cc7940 100644 --- a/packages/core/runtime/request-runner.ts +++ b/packages/core/runtime/request-runner.ts @@ -159,7 +159,7 @@ export async function runTrackedRequest( liveParsedState.delete(getStatusMessageKey(request)); const errorStatus = `Error: ${message}\n_${suggestion}_`; - await deps.im.updateMessage(request.channelId, request.statusMessageTs, errorStatus, false); + await deps.im.updateMessage(request.channelId, request.statusMessageTs, errorStatus); onFail(message); return { responses: null }; } diff --git a/packages/core/runtime/selection-reply.ts b/packages/core/runtime/selection-reply.ts index 7fb8230..27b0c8f 100644 --- a/packages/core/runtime/selection-reply.ts +++ b/packages/core/runtime/selection-reply.ts @@ -73,7 +73,7 @@ export async function handleSelectionReply(params: HandleSelectionReplyParams): const providerId = deps.agent.getProviderForSession(sessionId); const providerLabel = deps.agent.getDisplayNameForSession(sessionId); - const initialStatusTs = await deps.im.sendMessage(channelId, threadId, `${providerLabel} is running...`, false); + const initialStatusTs = await deps.im.sendMessage(channelId, threadId, `${providerLabel} is running...`); if (!initialStatusTs) { log.error("Failed to send status message for button selection"); return; @@ -142,7 +142,7 @@ export async function handleSelectionReply(params: HandleSelectionReplyParams): state: state.liveParsedState.get(statusMessageKey), statusMessageFormat: resolveStatusMessageFormat(), }); - const updatedStatusTs = await deps.im.updateMessage(channelId, statusTs, statusText, false); + const updatedStatusTs = await deps.im.updateMessage(channelId, statusTs, statusText); if (typeof updatedStatusTs === "string" && updatedStatusTs !== statusTs) { statusTs = updatedStatusTs; request.statusMessageTs = updatedStatusTs; diff --git a/packages/core/runtime/session-bootstrap.ts b/packages/core/runtime/session-bootstrap.ts index 3d9a238..4c04669 100644 --- a/packages/core/runtime/session-bootstrap.ts +++ b/packages/core/runtime/session-bootstrap.ts @@ -32,7 +32,7 @@ export async function prepareRuntimeSession(params: { try { cwd = resolveChannelCwd(channelId).cwd; } catch (err) { - await deps.im.sendMessage(channelId, replyThreadId, `Error: ${String(err)}`, false); + await deps.im.sendMessage(channelId, replyThreadId, `Error: ${String(err)}`); return null; } @@ -55,7 +55,7 @@ export async function prepareRuntimeSession(params: { threadId, error: String(err), }); - await deps.im.sendMessage(channelId, replyThreadId, `Error: ${message}\n_${suggestion}_`, false); + await deps.im.sendMessage(channelId, replyThreadId, `Error: ${message}\n_${suggestion}_`); return null; } @@ -74,7 +74,7 @@ export async function prepareRuntimeSession(params: { gitIdentity, }); if (worktree.skipped && worktree.message) { - await deps.im.sendMessage(channelId, replyThreadId, worktree.message, false); + await deps.im.sendMessage(channelId, replyThreadId, worktree.message); } cwd = resolvedCwd; } catch (err) { @@ -85,7 +85,7 @@ export async function prepareRuntimeSession(params: { sessionId, error: message, }); - await deps.im.sendMessage(channelId, replyThreadId, `Error: Failed to prepare worktree. ${message}`, false); + await deps.im.sendMessage(channelId, replyThreadId, `Error: Failed to prepare worktree. ${message}`); return null; } } diff --git a/packages/core/types.ts b/packages/core/types.ts index ec95fbe..0405a2f 100644 --- a/packages/core/types.ts +++ b/packages/core/types.ts @@ -52,12 +52,11 @@ export type AgentStatusMessageParams = { export interface IMAdapter { maxEditableMessageChars?: number; - sendMessage(channelId: string, threadId: string, text: string, asMarkdown?: boolean): Promise; + sendMessage(channelId: string, threadId: string, text: string): Promise; updateMessage( channelId: string, messageTs: string, - text: string, - asMarkdown?: boolean + text: string ): Promise; wasRateLimited?(channelId: string, messageTs: string): boolean; getRateLimitError?(channelId: string, messageTs: string): string | undefined; diff --git a/packages/ims/discord/client.ts b/packages/ims/discord/client.ts index 5ec8b60..48efcba 100644 --- a/packages/ims/discord/client.ts +++ b/packages/ims/discord/client.ts @@ -89,8 +89,7 @@ async function buildDiscordContext( async function sendMessage( _channelId: string, threadId: string, - text: string, - _asMarkdown = true + text: string ): Promise { const channel = await resolveTextChannel(threadId); const chunks = splitForDiscord(text); @@ -106,8 +105,7 @@ async function sendMessage( async function updateMessage( channelId: string, messageId: string, - text: string, - _asMarkdown = true + text: string ): Promise { try { const mappedThreadId = statusMessageThreadMap.get(messageId); diff --git a/packages/ims/discord/settings.ts b/packages/ims/discord/settings.ts index 62f54b7..d4d3afa 100644 --- a/packages/ims/discord/settings.ts +++ b/packages/ims/discord/settings.ts @@ -48,6 +48,7 @@ import { log } from "@/utils"; const DISCORD_MODAL_CHANNEL = "ode:modal:channel_details"; const DISCORD_MODAL_GITHUB = "ode:modal:github"; +const DISCORD_MODAL_GENERAL = "ode:modal:general"; const STATUS_FORMAT_OPTIONS = ["aggressive", "medium", "minimum"] as const; const STATUS_FREQUENCY_OPTIONS: StatusMessageFrequencyValue[] = STATUS_MESSAGE_FREQUENCY_OPTIONS.map((option) => option.value); @@ -363,6 +364,8 @@ function textInputRow(params: { } function buildChannelSettingsModal(channelId: string): ModalBuilder { + const provider = getChannelAgentProvider(channelId); + const model = getChannelModel(channelId) || ""; const baseBranch = getChannelBaseBranch(channelId) || "main"; const workingDirectory = resolveChannelCwd(channelId).workingDirectory || ""; const systemMessage = getChannelSystemMessage(channelId) || ""; @@ -371,6 +374,20 @@ function buildChannelSettingsModal(channelId: string): ModalBuilder { .setCustomId(`${DISCORD_MODAL_CHANNEL}:${channelId}`) .setTitle("Channel Settings") .addComponents( + textInputRow({ + id: "agent_provider", + label: "Agent provider", + required: true, + value: provider, + placeholder: PROVIDERS.join(", "), + }), + textInputRow({ + id: "model", + label: "Model", + required: false, + value: model, + placeholder: "Leave empty for provider default", + }), textInputRow({ id: "working_directory", label: "Working directory", @@ -420,6 +437,45 @@ function buildGitHubSettingsModal(channelId: string, userId: string): ModalBuild ); } +function buildGeneralSettingsModal(channelId: string): ModalBuilder { + const settings = getUserGeneralSettings(); + const statusFrequencyValue = toStatusMessageFrequencyValue(settings.statusMessageFrequencyMs); + + return new ModalBuilder() + .setCustomId(`${DISCORD_MODAL_GENERAL}:${channelId}`) + .setTitle("General Settings") + .addComponents( + textInputRow({ + id: "status_format", + label: "Status format", + required: true, + value: settings.defaultStatusMessageFormat, + placeholder: STATUS_FORMAT_OPTIONS.join(", "), + }), + textInputRow({ + id: "status_frequency", + label: "Status frequency (ms)", + required: true, + value: statusFrequencyValue, + placeholder: STATUS_FREQUENCY_OPTIONS.join(", "), + }), + textInputRow({ + id: "git_strategy", + label: "Git strategy", + required: true, + value: settings.gitStrategy, + placeholder: GIT_STRATEGY_OPTIONS.join(", "), + }), + textInputRow({ + id: "auto_update", + label: "Auto update", + required: true, + value: settings.autoUpdate ? "on" : "off", + placeholder: AUTO_UPDATE_OPTIONS.join(", "), + }) + ); +} + async function handleLauncherButtonInteraction(interaction: any): Promise { const customId = String(interaction.customId ?? ""); if (!customId.startsWith("ode:launcher:")) return false; @@ -435,11 +491,7 @@ async function handleLauncherButtonInteraction(interaction: any): Promise const channelId = parts[3] || getResolvedChannelId(interaction); if (modalKind === DISCORD_MODAL_CHANNEL) { + const providerValue = getModalValue(interaction, "agent_provider").trim(); + const parsedProvider = parseProvider(providerValue); + if (!parsedProvider || !isAgentEnabled(parsedProvider)) { + await interaction.reply({ + content: `Invalid provider. Use one of: ${PROVIDERS.join(", ")}`, + flags: MessageFlags.Ephemeral, + }); + return true; + } + + const modelInput = getModalValue(interaction, "model").trim(); + const providerModels = getProviderModels(parsedProvider); + if (providerModels.length > 0 && modelInput && !findMatchingModel(providerModels, modelInput)) { + await interaction.reply({ + content: "Model is not available for the selected provider.", + flags: MessageFlags.Ephemeral, + }); + return true; + } + const workingDirectory = getModalValue(interaction, "working_directory").trim(); const baseBranch = getModalValue(interaction, "base_branch").trim() || "main"; const channelSystemMessage = getModalValue(interaction, "channel_system_message"); + + setChannelAgentProvider(channelId, parsedProvider); + setChannelModel(channelId, resolveStoredModelForProvider({ + provider: parsedProvider, + selectedModel: modelInput, + lists: getProviderModelLists(), + })); setChannelWorkingDirectory(channelId, workingDirectory.length > 0 ? workingDirectory : null); setChannelBaseBranch(channelId, baseBranch); setChannelSystemMessage(channelId, channelSystemMessage); @@ -490,6 +565,58 @@ async function handleModalSubmitInteraction(interaction: any): Promise return true; } + if (modalKind === DISCORD_MODAL_GENERAL) { + const statusFormatRaw = getModalValue(interaction, "status_format"); + const statusFormat = parseGeneralStatusFormat(statusFormatRaw); + if (!statusFormat) { + await interaction.reply({ + content: `Invalid status format. Use one of: ${STATUS_FORMAT_OPTIONS.join(", ")}`, + flags: MessageFlags.Ephemeral, + }); + return true; + } + + const statusFrequencyRaw = getModalValue(interaction, "status_frequency"); + const statusFrequency = parseStatusFrequency(statusFrequencyRaw); + if (!statusFrequency) { + await interaction.reply({ + content: `Invalid status frequency. Use one of: ${STATUS_FREQUENCY_OPTIONS.join(", ")}`, + flags: MessageFlags.Ephemeral, + }); + return true; + } + + const gitStrategyRaw = getModalValue(interaction, "git_strategy"); + const gitStrategy = parseGitStrategy(gitStrategyRaw); + if (!gitStrategy) { + await interaction.reply({ + content: `Invalid git strategy. Use one of: ${GIT_STRATEGY_OPTIONS.join(", ")}`, + flags: MessageFlags.Ephemeral, + }); + return true; + } + + const autoUpdateRaw = getModalValue(interaction, "auto_update"); + const autoUpdate = parseAutoUpdate(autoUpdateRaw); + if (!autoUpdate) { + await interaction.reply({ + content: `Invalid auto update value. Use one of: ${AUTO_UPDATE_OPTIONS.join(", ")}`, + flags: MessageFlags.Ephemeral, + }); + return true; + } + + setUserGeneralSettings({ + defaultStatusMessageFormat: statusFormat, + gitStrategy, + statusMessageFrequencyMs: parseStatusMessageFrequencyMs(Number(statusFrequency)), + autoUpdate: autoUpdate !== "off", + }); + + await interaction.reply({ content: "General settings updated.", flags: MessageFlags.Ephemeral }); + return true; + } + if (modalKind === DISCORD_MODAL_GITHUB) { const token = getModalValue(interaction, "github_token").trim(); const gitName = getModalValue(interaction, "github_name"); diff --git a/packages/ims/lark/client.ts b/packages/ims/lark/client.ts index 2ae78a3..f8eaff2 100644 --- a/packages/ims/lark/client.ts +++ b/packages/ims/lark/client.ts @@ -210,9 +210,8 @@ async function sendLarkMessage(params: { return messageId; } -function buildLarkPostContent(text: string, asMarkdown: boolean): Record { - const tag = asMarkdown ? "md" : "text"; - const block = [{ tag, text }]; +function buildLarkPostContent(text: string): Record { + const block = [{ tag: "md", text }]; return { zh_cn: { title: "", @@ -225,11 +224,6 @@ function buildLarkPostContent(text: string, asMarkdown: boolean): Record]*>.*?<\/at>/g, " ") @@ -300,15 +294,13 @@ async function buildLarkContext( async function sendMessage( channelId: string, threadId: string, - text: string, - asMarkdown = true + text: string ): Promise { - const useMarkdown = shouldUseLarkMarkdown(text, asMarkdown); return sendLarkMessage({ channelId, threadId: threadId || "", msgType: "post", - content: buildLarkPostContent(text, useMarkdown), + content: buildLarkPostContent(text), }); } @@ -323,7 +315,7 @@ async function sendSettingsCard(channelId: string, threadId: string): Promise sendMessage(channelId, threadId, text, true), + sendText: (text) => sendMessage(channelId, threadId, text), logEvent: logLarkEvent, }); } @@ -331,8 +323,7 @@ async function sendSettingsCard(channelId: string, threadId: string): Promise { const creds = getLarkCredentialsForChannel(channelId); if (!creds) return; @@ -343,7 +334,7 @@ async function updateMessage( const trackedThreadId = sentMessageThreadMap.get(messageId)?.threadId || findReplyThreadIdByStatusMessageTs(messageId) || ""; try { await deleteMessage(channelId, messageId); - const replacementMessageId = await sendMessage(channelId, trackedThreadId, text, asMarkdown); + const replacementMessageId = await sendMessage(channelId, trackedThreadId, text); if (replacementMessageId) { larkMessageEditCounts.delete(messageId); larkMessageEditCounts.set(replacementMessageId, 0); @@ -370,10 +361,9 @@ async function updateMessage( } } - const useMarkdown = shouldUseLarkMarkdown(text, asMarkdown); const payload = { msg_type: "post", - content: JSON.stringify(buildLarkPostContent(text, useMarkdown)), + content: JSON.stringify(buildLarkPostContent(text)), }; try { @@ -1124,7 +1114,7 @@ async function processLarkIncomingEvent(event: LarkIncomingEvent): Promise markThreadActive, handleStopCommand: (flowChannelId, flowThreadId) => coreRuntime.handleStopCommand(flowChannelId, flowThreadId), sendStopAck: async () => { - await sendMessage(channelId, threadId, "Request stopped.", true); + await sendMessage(channelId, threadId, "Request stopped."); }, onIgnore: (reason) => { if (reason === "not_mentioned_and_inactive") { diff --git a/packages/ims/slack/client.ts b/packages/ims/slack/client.ts index bdf8c0c..edd1ecb 100644 --- a/packages/ims/slack/client.ts +++ b/packages/ims/slack/client.ts @@ -311,11 +311,10 @@ export async function initializeWorkspaceAuth(): Promise { export async function sendMessage( channelId: string, threadId: string, - text: string, - asMarkdown = true + text: string ): Promise { const slackApp = getApp(); - const formattedText = asMarkdown ? markdownToSlack(text) : text; + const formattedText = markdownToSlack(text); const chunks = splitForSlack(formattedText); const workspace = channelWorkspaceMap.get(channelId) || "unknown"; const botToken = getSlackBotToken(channelId); @@ -368,12 +367,11 @@ export async function deleteMessage( export async function updateMessage( channelId: string, messageTs: string, - text: string, - asMarkdown = true + text: string ): Promise { try { const slackApp = getApp(); - const formattedText = asMarkdown ? markdownToSlack(text) : text; + const formattedText = markdownToSlack(text); const truncatedText = truncateForSlack(formattedText); const botToken = getSlackBotToken(channelId); if (!botToken) { @@ -439,8 +437,7 @@ export async function recoverPendingRequests(): Promise { await updateMessage( pendingRestart.channelId, pendingRestart.messageTs, - "Restarting Ode complete.", - false + "Restarting Ode complete." ); }