diff --git a/packages/ims/lark/client.ts b/packages/ims/lark/client.ts index 3b401b8..37103b0 100644 --- a/packages/ims/lark/client.ts +++ b/packages/ims/lark/client.ts @@ -6,6 +6,16 @@ import { getGitHubInfoForUser, getLarkAppCredentials, getLarkTargetChannels, + getUserGeneralSettings, + parseStatusMessageFrequencyMs, + setChannelAgentProvider, + setChannelBaseBranch, + setChannelModel, + setChannelSystemMessage, + setChannelWorkingDirectory, + setGitHubInfoForUser, + setUserGeneralSettings, + type StatusMessageFormat, getWorkspaces, } from "@/config"; import { findReplyThreadIdByStatusMessageTs } from "@/config/local/sessions"; @@ -23,7 +33,11 @@ import { executeIncomingFlow } from "@/ims/shared/incoming-executor"; import { buildIncomingContext } from "@/ims/shared/incoming-normalizer"; import { parseIncomingCommand } from "@/ims/shared/command-router"; import { createRuntimeController } from "@/ims/shared/runtime-controller"; -import { sendLarkSettingsCard } from "./settings"; +import { + buildLarkSettingsDetailCard, + resolveLarkSettingsCardAction, + sendLarkSettingsCard, +} from "./settings"; let larkRuntimeStarted = false; @@ -608,8 +622,386 @@ type LarkIncomingEnvelope = { }; }; +type LarkCardActionEnvelope = { + action?: { + value?: unknown; + }; + open_chat_id?: string; + chat_id?: string; + open_message_id?: string; + message_id?: string; + open_id?: string; + user_id?: string; + event?: { + action?: { + value?: unknown; + }; + context?: { + open_chat_id?: string; + chat_id?: string; + open_message_id?: string; + message_id?: string; + }; + operator?: { + open_id?: string; + user_id?: string; + }; + }; +}; + type LarkIncomingEvent = NonNullable; +function toTrimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function firstNonEmptyString(...values: unknown[]): string { + for (const value of values) { + const normalized = toTrimmedString(value); + if (normalized.length > 0) return normalized; + } + return ""; +} + +function pickValueField(value: unknown, key: string): string { + if (!value || typeof value !== "object") return ""; + const record = value as Record; + return firstNonEmptyString(record[key], record[key.replace(/_([a-z])/g, (_, s) => s.toUpperCase())]); +} + +function pickActionSelectedOption(payload: unknown): string { + if (!payload || typeof payload !== "object") return ""; + const root = payload as Record; + const event = root.event && typeof root.event === "object" ? root.event as Record : null; + const action = event?.action && typeof event.action === "object" ? event.action as Record : null; + const option = action?.option && typeof action.option === "object" ? action.option as Record : null; + const options = action?.options && Array.isArray(action.options) ? action.options as unknown[] : []; + + const fromOption = firstNonEmptyString(option?.value); + if (fromOption) return fromOption; + + for (const item of options) { + if (!item || typeof item !== "object") continue; + const value = firstNonEmptyString((item as Record).value); + if (value) return value; + } + + return ""; +} + +function extractFormValues(payload: unknown): Record { + if (!payload || typeof payload !== "object") return {}; + const root = payload as Record; + const event = root.event && typeof root.event === "object" ? root.event as Record : null; + const action = event?.action && typeof event.action === "object" ? event.action as Record : null; + const form = action?.form_value && typeof action.form_value === "object" + ? action.form_value as Record + : root.form_value && typeof root.form_value === "object" + ? root.form_value as Record + : {}; + const normalized: Record = {}; + for (const [key, value] of Object.entries(form)) { + if (typeof value === "string") { + normalized[key] = value; + continue; + } + if (!value || typeof value !== "object") continue; + const record = value as Record; + const directValue = firstNonEmptyString(record.value); + if (directValue) { + normalized[key] = directValue; + continue; + } + const option = record.option && typeof record.option === "object" ? record.option as Record : null; + const optionValue = firstNonEmptyString(option?.value); + if (optionValue) { + normalized[key] = optionValue; + continue; + } + const options = Array.isArray(record.options) ? record.options : []; + for (const item of options) { + if (!item || typeof item !== "object") continue; + const itemValue = firstNonEmptyString((item as Record).value); + if (itemValue) { + normalized[key] = itemValue; + break; + } + } + } + return normalized; +} + +function pickFormValue( + formValues: Record, + key: string +): { exists: boolean; value: string } { + if (Object.prototype.hasOwnProperty.call(formValues, key)) { + return { + exists: true, + value: formValues[key] ?? "", + }; + } + return { + exists: false, + value: "", + }; +} + +async function processLarkCardAction(payload: unknown): Promise { + if (!payload || typeof payload !== "object") return; + const envelope = payload as LarkCardActionEnvelope; + const value = envelope.event?.action?.value ?? envelope.action?.value; + const action = resolveLarkSettingsCardAction(value); + if (!action) return; + + const channelId = firstNonEmptyString( + envelope.event?.context?.open_chat_id, + envelope.event?.context?.chat_id, + envelope.open_chat_id, + envelope.chat_id, + pickValueField(value, "channel_id"), + pickValueField(value, "channelId") + ); + const sourceMessageId = firstNonEmptyString( + envelope.event?.context?.open_message_id, + envelope.event?.context?.message_id, + envelope.open_message_id, + envelope.message_id + ); + const threadId = firstNonEmptyString( + pickValueField(value, "thread_id"), + pickValueField(value, "threadId"), + sourceMessageId + ); + const userId = firstNonEmptyString( + envelope.event?.operator?.open_id, + envelope.event?.operator?.user_id, + envelope.open_id, + envelope.user_id + ); + + if ( + action === "set_general_status_format" + || action === "set_general_status_frequency" + || action === "set_general_git_strategy" + || action === "set_general_auto_update" + ) { + const current = getUserGeneralSettings(); + if (action === "set_general_status_format") { + const format = firstNonEmptyString( + pickValueField(value, "status_format"), + pickValueField(value, "statusFormat"), + pickActionSelectedOption(payload) + ); + if (format === "minimum" || format === "medium" || format === "aggressive") { + current.defaultStatusMessageFormat = format as StatusMessageFormat; + } + } else if (action === "set_general_status_frequency") { + const raw = firstNonEmptyString( + pickValueField(value, "status_frequency_ms"), + pickValueField(value, "statusFrequencyMs"), + pickActionSelectedOption(payload) + ); + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) { + current.statusMessageFrequencyMs = parseStatusMessageFrequencyMs(parsed); + } + } else if (action === "set_general_git_strategy") { + const strategy = firstNonEmptyString( + pickValueField(value, "git_strategy"), + pickValueField(value, "gitStrategy"), + pickActionSelectedOption(payload) + ); + current.gitStrategy = strategy === "default" ? "default" : "worktree"; + } else if (action === "set_general_auto_update") { + const autoUpdate = firstNonEmptyString( + pickValueField(value, "auto_update"), + pickValueField(value, "autoUpdate"), + pickActionSelectedOption(payload) + ).toLowerCase(); + current.autoUpdate = !(autoUpdate === "off" || autoUpdate === "false" || autoUpdate === "0"); + } + setUserGeneralSettings(current); + } + + if (action === "set_channel_settings") { + const formValues = extractFormValues(payload); + const selected = pickActionSelectedOption(payload); + const field = firstNonEmptyString( + pickValueField(value, "field") + ); + const formModel = pickFormValue(formValues, "model"); + const formWorkingDirectory = pickFormValue(formValues, "workingDirectory"); + const formBaseBranch = pickFormValue(formValues, "baseBranch"); + const formSystemMessage = pickFormValue(formValues, "channelSystemMessage"); + const provider = firstNonEmptyString( + pickValueField(value, "provider"), + field === "provider" ? selected : "" + ); + if ( + provider === "opencode" + || provider === "claudecode" + || provider === "codex" + || provider === "kimi" + || provider === "kiro" + || provider === "kilo" + || provider === "qwen" + || provider === "goose" + || provider === "gemini" + ) { + setChannelAgentProvider(channelId, provider); + } + + const model = formModel.exists + ? formModel.value + : firstNonEmptyString( + pickValueField(value, "model"), + field === "model" ? selected : "" + ); + setChannelModel(channelId, model); + + const workingDirectory = formWorkingDirectory.exists + ? formWorkingDirectory.value + : firstNonEmptyString( + pickValueField(value, "working_directory"), + pickValueField(value, "workingDirectory"), + field === "workingDirectory" ? selected : "" + ); + setChannelWorkingDirectory(channelId, workingDirectory || null); + + const baseBranch = formBaseBranch.exists + ? formBaseBranch.value + : firstNonEmptyString( + pickValueField(value, "base_branch"), + pickValueField(value, "baseBranch"), + field === "baseBranch" ? selected : "" + ); + setChannelBaseBranch(channelId, baseBranch || null); + + const channelSystemMessage = formSystemMessage.exists + ? formSystemMessage.value + : firstNonEmptyString( + pickValueField(value, "channel_system_message"), + pickValueField(value, "channelSystemMessage"), + field === "channelSystemMessage" ? selected : "" + ); + setChannelSystemMessage(channelId, channelSystemMessage || null); + } + + if (action === "set_github_info") { + const formValues = extractFormValues(payload); + const selected = pickActionSelectedOption(payload); + const field = firstNonEmptyString(pickValueField(value, "field")); + const formGithubToken = pickFormValue(formValues, "githubToken"); + const formGithubName = pickFormValue(formValues, "githubName"); + const formGithubEmail = pickFormValue(formValues, "githubEmail"); + const token = formGithubToken.exists + ? formGithubToken.value + : firstNonEmptyString( + pickValueField(value, "github_token"), + pickValueField(value, "githubToken"), + field === "githubToken" ? selected : "" + ); + const gitName = formGithubName.exists + ? formGithubName.value + : firstNonEmptyString( + pickValueField(value, "git_name"), + pickValueField(value, "github_name"), + pickValueField(value, "gitName"), + pickValueField(value, "githubName"), + field === "githubName" ? selected : "" + ); + const gitEmail = formGithubEmail.exists + ? formGithubEmail.value + : firstNonEmptyString( + pickValueField(value, "git_email"), + pickValueField(value, "github_email"), + pickValueField(value, "gitEmail"), + pickValueField(value, "githubEmail"), + field === "githubEmail" ? selected : "" + ); + setGitHubInfoForUser(userId || "", { + token, + gitName, + gitEmail, + }); + } + + if (action === "clear_github_info") { + setGitHubInfoForUser(userId || "", { + token: "", + gitName: "", + gitEmail: "", + }); + } + + if (!channelId || !threadId) { + logLarkEvent("Lark card action ignored: missing routing ids", { + channelId, + threadId, + sourceMessageId, + action, + }); + return; + } + + const card = action === "open_settings_launcher" + ? null + : buildLarkSettingsDetailCard({ + action: ( + action === "set_general_status_format" + || action === "set_general_status_frequency" + || action === "set_general_git_strategy" + || action === "set_general_auto_update" + || action === "set_channel_settings" + || action === "set_github_info" + || action === "clear_github_info" + ) + ? ( + action === "set_channel_settings" + ? "open_settings_modal" + : action === "set_github_info" || action === "clear_github_info" + ? "open_github_token_modal" + : "open_general_settings_modal" + ) + : action, + channelId, + threadId, + userId: userId || "", + notice: ( + action === "set_general_status_format" + || action === "set_general_status_frequency" + || action === "set_general_git_strategy" + || action === "set_general_auto_update" + || action === "set_channel_settings" + || action === "set_github_info" + || action === "clear_github_info" + ) + ? ( + action === "set_channel_settings" + ? "Channel settings updated" + : action === "set_github_info" + ? "GitHub settings updated" + : action === "clear_github_info" + ? "GitHub settings cleared" + : "General settings updated" + ) + : undefined, + }); + + if (!card) { + await sendSettingsCard(channelId, threadId); + return; + } + + await sendLarkMessage({ + channelId, + threadId, + msgType: "interactive", + content: card, + }); +} + function isLarkLongConnectionEnabled(): boolean { const raw = process.env.LARK_LONG_CONNECTION?.trim().toLowerCase(); if (!raw) return true; @@ -798,6 +1190,16 @@ async function startLarkLongConnections(reason: string): Promise { }); } }, + "card.action.trigger": async (data: unknown) => { + try { + await processLarkCardAction(data); + } catch (error) { + log.warn("Failed to handle Lark long-connection card action", { + appId, + error: String(error), + }); + } + }, }); const wsClient = new Lark.WSClient({ @@ -849,7 +1251,34 @@ export async function handleLarkEventPayload(payload: unknown): Promise<{ status return { status: 200, body: { challenge: envelope.challenge } }; } + const payloadRecord = payload as Record; + const payloadEvent = payloadRecord.event && typeof payloadRecord.event === "object" + ? payloadRecord.event as Record + : null; + const payloadEventAction = payloadEvent?.action && typeof payloadEvent.action === "object" + ? payloadEvent.action as Record + : null; + const payloadAction = payloadRecord.action && typeof payloadRecord.action === "object" + ? payloadRecord.action as Record + : null; + const cardAction = resolveLarkSettingsCardAction(payloadEventAction?.value) + || resolveLarkSettingsCardAction(payloadAction?.value) + || resolveLarkSettingsCardAction(payloadEventAction) + || resolveLarkSettingsCardAction(payloadAction); + if (cardAction) { + await processLarkCardAction(payload); + return { status: 200, body: { code: 0 } }; + } + if (envelope.header?.event_type !== "im.message.receive_v1") { + if (envelope.header?.event_type === "card.action.trigger") { + await processLarkCardAction(payload); + return { + status: 200, + body: { code: 0 }, + }; + } + logLarkEvent("Lark webhook ignored: unsupported event type", { eventType: envelope.header?.event_type ?? "", }); diff --git a/packages/ims/lark/settings.ts b/packages/ims/lark/settings.ts index 9af6605..0f06a50 100644 --- a/packages/ims/lark/settings.ts +++ b/packages/ims/lark/settings.ts @@ -1,25 +1,65 @@ -import { getWebHost, getWebPort } from "@/config"; +import { + getCodexModels, + getChannelAgentProvider, + getChannelBaseBranch, + getChannelModel, + getChannelSystemMessage, + getEnabledAgentProviders, + getGitHubInfoForUser, + getKiloModels, + getOpenCodeModels, + STATUS_MESSAGE_FREQUENCY_OPTIONS, + getUserGeneralSettings, + getWebHost, + getWebPort, + getWorkspaces, + resolveChannelCwd, +} from "@/config"; + +export type LarkSettingsCardAction = + | "open_settings_launcher" + | "open_general_settings_modal" + | "open_settings_modal" + | "open_github_token_modal" + | "set_general_status_format" + | "set_general_status_frequency" + | "set_general_git_strategy" + | "set_general_auto_update" + | "set_channel_settings" + | "set_github_info" + | "clear_github_info"; + +const SETTINGS_LAUNCHER_ACTIONS: Array<{ action: LarkSettingsCardAction; label: string; style?: "primary" | "default" }> = [ + { action: "open_general_settings_modal", label: "General setting", style: "primary" }, + { action: "open_settings_modal", label: "Channel setting" }, + { action: "open_github_token_modal", label: "GitHub info" }, +]; + +function toWorkspaceSlug(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +function getWorkspaceNameByChannel(channelId: string): string { + for (const workspace of getWorkspaces()) { + if (workspace.type !== "lark") continue; + if (workspace.channelDetails.some((detail) => detail.id === channelId)) { + return workspace.name || "workspace"; + } + } + const fallback = getWorkspaces().find((workspace) => workspace.type === "lark"); + return fallback?.name || "workspace"; +} function getLocalSettingsUrl(): string { return `http://${getWebHost()}:${getWebPort()}/`; } -export async function sendLarkSettingsCard(params: { - channelId: string; - threadId: string; - sendInteractive: (card: Record) => Promise; - sendText: (text: string) => Promise; - logEvent: (message: string, payload: Record) => void; -}): Promise { - const { channelId, threadId, sendInteractive, sendText, logEvent } = params; - const settingsUrl = getLocalSettingsUrl(); - logEvent("Lark settings UI launcher triggered", { - channelId, - threadId, - settingsUrl, - }); - - const card = { +function buildSettingsLauncherCard(channelId: string, threadId: string): Record { + return { config: { wide_screen_mode: true, }, @@ -33,24 +73,596 @@ export async function sendLarkSettingsCard(params: { elements: [ { tag: "markdown", - content: `Configure this chat in the local settings UI.\n\nChannel: \`${channelId}\``, + content: `Choose which settings page to open.\n\nChannel: \`${channelId}\``, + }, + { + tag: "action", + actions: SETTINGS_LAUNCHER_ACTIONS.map((item) => ({ + tag: "button", + text: { + tag: "plain_text", + content: item.label, + }, + type: item.style ?? "default", + value: { + action: item.action, + channelId, + threadId, + }, + })), + }, + ], + }; +} + +export function resolveLarkSettingsCardAction(value: unknown): LarkSettingsCardAction | null { + if (typeof value === "string") { + const normalized = value.trim(); + if ( + normalized === "open_settings_launcher" + || normalized === "open_general_settings_modal" + || normalized === "open_settings_modal" + || normalized === "open_github_token_modal" + || normalized === "set_general_status_format" + || normalized === "set_general_status_frequency" + || normalized === "set_general_git_strategy" + || normalized === "set_general_auto_update" + || normalized === "set_channel_settings" + || normalized === "set_github_info" + || normalized === "clear_github_info" + ) { + return normalized; + } + return null; + } + + if (value && typeof value === "object") { + const record = value as Record; + return resolveLarkSettingsCardAction(record.action ?? record.action_id ?? record.actionId ?? record.type); + } + + return null; +} + +function boolText(value: boolean): string { + return value ? "On" : "Off"; +} + +function maskedToken(token: string | undefined): string { + if (!token) return "(not set)"; + if (token.length <= 8) return "********"; + return `${token.slice(0, 4)}...${token.slice(-4)}`; +} + +export function buildLarkSettingsDetailCard(params: { + action: LarkSettingsCardAction; + channelId: string; + threadId: string; + userId: string; + notice?: string; +}): Record { + const { action, channelId, threadId, userId, notice } = params; + const workspaceSlug = encodeURIComponent(toWorkspaceSlug(getWorkspaceNameByChannel(channelId)) || "workspace"); + const localSettingsUrl = getLocalSettingsUrl(); + const workspaceUrl = `${localSettingsUrl}slack-bot/${workspaceSlug}`; + + if (action === "open_general_settings_modal") { + const general = getUserGeneralSettings(); + const statusFormatOptions = [ + { value: "minimum", label: "Minimum" }, + { value: "medium", label: "Medium" }, + { value: "aggressive", label: "Aggressive" }, + ]; + const gitOptions = [ + { value: "worktree", label: "Worktree" }, + { value: "default", label: "Default" }, + ]; + const autoUpdateOptions = [ + { value: "on", label: "On" }, + { value: "off", label: "Off" }, + ]; + + const elements: Array> = []; + if (notice) { + elements.push({ + tag: "markdown", + content: `✅ ${notice}`, + }); + } + + elements.push( + { + tag: "markdown", + content: "General", + }, + { + tag: "markdown", + content: `Status message format\nCurrent: \`${general.defaultStatusMessageFormat}\``, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select format" }, + options: statusFormatOptions.map((item) => ({ + text: { tag: "plain_text", content: item.label }, + value: item.value, + })), + value: { + action: "set_general_status_format", + current: general.defaultStatusMessageFormat, + channelId, + threadId, + }, + }, + ], + }, + { + tag: "markdown", + content: `Status message frequency\nCurrent: \`${general.statusMessageFrequencyMs / 1000}s\``, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select frequency" }, + options: STATUS_MESSAGE_FREQUENCY_OPTIONS.map((item) => ({ + text: { tag: "plain_text", content: item.label }, + value: String(item.ms), + })), + value: { + action: "set_general_status_frequency", + current: String(general.statusMessageFrequencyMs), + channelId, + threadId, + }, + }, + ], + }, + { + tag: "markdown", + content: `Git strategy\nCurrent: \`${general.gitStrategy}\``, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select strategy" }, + options: gitOptions.map((item) => ({ + text: { tag: "plain_text", content: item.label }, + value: item.value, + })), + value: { + action: "set_general_git_strategy", + current: general.gitStrategy, + channelId, + threadId, + }, + }, + ], + }, + { + tag: "markdown", + content: `Auto update\nCurrent: \`${boolText(general.autoUpdate)}\``, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select auto update" }, + options: autoUpdateOptions.map((item) => ({ + text: { tag: "plain_text", content: item.label }, + value: item.value, + })), + value: { + action: "set_general_auto_update", + current: general.autoUpdate ? "on" : "off", + channelId, + threadId, + }, + }, + ], }, { tag: "action", actions: [ { tag: "button", - text: { - tag: "plain_text", - content: "Open Local Setting", + type: "primary", + text: { tag: "plain_text", content: "Back" }, + value: { action: "open_settings_launcher", channelId, threadId }, + }, + ], + } + ); + + return { + config: { wide_screen_mode: true }, + header: { + template: "blue", + title: { tag: "plain_text", content: "General setting" }, + }, + elements, + }; + } + + if (action === "open_settings_modal") { + const provider = getChannelAgentProvider(channelId); + const model = getChannelModel(channelId) || "(not set)"; + const cwd = resolveChannelCwd(channelId).workingDirectory || "(not set)"; + const baseBranch = getChannelBaseBranch(channelId); + const systemMessage = getChannelSystemMessage(channelId) || "(none)"; + const enabledProviders = getEnabledAgentProviders(); + const providerOptions = (enabledProviders.length > 0 ? enabledProviders : [ + "opencode", + "claudecode", + "codex", + "kimi", + "kiro", + "kilo", + "qwen", + "goose", + "gemini", + ]).map((item) => ({ + text: { tag: "plain_text", content: item }, + value: item, + })); + const providerModels = provider === "codex" + ? getCodexModels() + : provider === "kilo" + ? getKiloModels() + : provider === "opencode" + ? getOpenCodeModels() + : []; + const modelOptions = Array.from(new Set([...(model !== "(not set)" ? [model] : []), ...providerModels])) + .filter((item) => item && item.trim().length > 0) + .slice(0, 100) + .map((item) => ({ + text: { tag: "plain_text", content: item }, + value: item, + })); + const workingDirectoryOptions = Array.from(new Set([ + cwd === "(not set)" ? "" : cwd, + "/root/ode-new", + "/root", + "/", + ])).map((item) => ({ + text: { tag: "plain_text", content: item || "(empty)" }, + value: item, + })); + const baseBranchOptions = Array.from(new Set([ + baseBranch, + "main", + "master", + "develop", + "dev", + ])).map((item) => ({ + text: { tag: "plain_text", content: item || "main" }, + value: item || "main", + })); + const channelSystemMessageOptions = [ + { text: "(empty)", value: "" }, + { text: "concise", value: "Please keep responses concise and actionable." }, + { text: "detailed", value: "Please provide detailed reasoning and include implementation notes." }, + { text: "current", value: systemMessage === "(none)" ? "" : systemMessage }, + ] + .filter((item, idx, all) => all.findIndex((x) => x.value === item.value) === idx) + .map((item) => ({ + text: { tag: "plain_text", content: item.text }, + value: item.value, + })); + const settingsBaseValue = { + action: "set_channel_settings", + channelId, + threadId, + provider, + model: model === "(not set)" ? "" : model, + workingDirectory: cwd === "(not set)" ? "" : cwd, + baseBranch, + channelSystemMessage: systemMessage === "(none)" ? "" : systemMessage, + }; + + return { + config: { wide_screen_mode: true }, + header: { + template: "turquoise", + title: { tag: "plain_text", content: "Channel setting" }, + }, + elements: [ + ...(notice + ? [ + { + tag: "markdown", + content: `✅ ${notice}`, + }, + ] + : []), + { + tag: "markdown", + content: "Coding Agent", + }, + { + tag: "markdown", + content: `Provider\nCurrent: \`${provider}\``, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select provider" }, + options: providerOptions, + value: { + ...settingsBaseValue, + field: "provider", + }, + }, + ], + }, + { + tag: "markdown", + content: `Model\nCurrent: \`${model}\``, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select model" }, + options: modelOptions.length > 0 + ? modelOptions + : [{ text: { tag: "plain_text", content: "(empty)" }, value: "" }], + value: { + ...settingsBaseValue, + field: "model", + }, + }, + ], + }, + { + tag: "markdown", + content: "Execution", + }, + { + tag: "markdown", + content: `Working directory\nCurrent: \`${cwd}\``, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select working directory" }, + options: workingDirectoryOptions, + value: { + ...settingsBaseValue, + field: "workingDirectory", + }, + }, + ], + }, + { + tag: "markdown", + content: `Base branch\nCurrent: \`${baseBranch}\``, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select base branch" }, + options: baseBranchOptions, + value: { + ...settingsBaseValue, + field: "baseBranch", + }, + }, + ], + }, + { + tag: "markdown", + content: `System message\nCurrent: ${systemMessage}`, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select system message" }, + options: channelSystemMessageOptions, + value: { + ...settingsBaseValue, + field: "channelSystemMessage", + }, + }, + ], + }, + { + tag: "action", + actions: [ + { + tag: "button", + type: "primary", + text: { tag: "plain_text", content: "Save channel setting" }, + value: { + ...settingsBaseValue, + field: "save", + }, + }, + { + tag: "button", + type: "default", + text: { tag: "plain_text", content: "Open channel page" }, + url: workspaceUrl, + }, + { + tag: "button", + type: "primary", + text: { tag: "plain_text", content: "Back" }, + value: { action: "open_settings_launcher", channelId, threadId }, + }, + ], + }, + ], + }; + } + + const github = getGitHubInfoForUser(userId); + const githubToken = github?.token || ""; + const githubName = github?.gitName || ""; + const githubEmail = github?.gitEmail || ""; + const githubBaseValue = { + action: "set_github_info", + channelId, + threadId, + githubToken, + githubName, + githubEmail, + }; + return { + config: { wide_screen_mode: true }, + header: { + template: "violet", + title: { tag: "plain_text", content: "GitHub info" }, + }, + elements: [ + ...(notice + ? [ + { + tag: "markdown", + content: `✅ ${notice}`, + }, + ] + : []), + { + tag: "markdown", + content: "GitHub", + }, + { + tag: "markdown", + content: `Token\nCurrent: \`${maskedToken(github?.token)}\``, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Token action" }, + options: [ + { text: { tag: "plain_text", content: "Keep current" }, value: githubToken }, + { text: { tag: "plain_text", content: "Clear token" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubToken", + }, + }, + ], + }, + { + tag: "markdown", + content: `Git name\nCurrent: \`${github?.gitName || "(not set)"}\``, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select git name" }, + options: [ + { text: { tag: "plain_text", content: githubName || "(not set)" }, value: githubName }, + { text: { tag: "plain_text", content: "LIU9293" }, value: "LIU9293" }, + { text: { tag: "plain_text", content: "(empty)" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubName", }, + }, + ], + }, + { + tag: "markdown", + content: `Git email\nCurrent: \`${github?.gitEmail || "(not set)"}\``, + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select git email" }, + options: [ + { text: { tag: "plain_text", content: githubEmail || "(not set)" }, value: githubEmail }, + { text: { tag: "plain_text", content: "mylock.kai@gmail.com" }, value: "mylock.kai@gmail.com" }, + { text: { tag: "plain_text", content: "(empty)" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubEmail", + }, + }, + ], + }, + { + tag: "action", + actions: [ + { + tag: "button", + type: "primary", + text: { tag: "plain_text", content: "Save GitHub setting" }, + value: { + ...githubBaseValue, + field: "save", + }, + }, + { + tag: "button", + type: "danger", + text: { tag: "plain_text", content: "Clear" }, + value: { + action: "clear_github_info", + channelId, + threadId, + }, + }, + { + tag: "button", + type: "default", + text: { tag: "plain_text", content: "Open workspace page" }, + url: workspaceUrl, + }, + { + tag: "button", type: "primary", - url: settingsUrl, + text: { tag: "plain_text", content: "Back" }, + value: { action: "open_settings_launcher", channelId, threadId }, }, ], }, ], }; +} + +export async function sendLarkSettingsCard(params: { + channelId: string; + threadId: string; + sendInteractive: (card: Record) => Promise; + sendText: (text: string) => Promise; + logEvent: (message: string, payload: Record) => void; +}): Promise { + const { channelId, threadId, sendInteractive, sendText, logEvent } = params; + const settingsUrl = getLocalSettingsUrl(); + logEvent("Lark settings UI launcher triggered", { + channelId, + threadId, + settingsUrl, + }); + + const card = buildSettingsLauncherCard(channelId, threadId); try { const messageId = await sendInteractive(card as unknown as Record);