From 24ba96a3de27dda8eefea58e3fdb1784a0221a16 Mon Sep 17 00:00:00 2001 From: Hanish Keloth Date: Wed, 28 Jan 2026 15:19:45 +0900 Subject: [PATCH] feat(channels): add KakaoWork channel plugin Add KakaoWork Bot API integration for Korea-focused enterprise messaging. - Support for DM conversations via KakaoWork Web API - Reactive callback handling for user interactions - Multi-account configuration support - Pairing-based DM access control - Rate limit aware (200 req/min) Contributed-by: Hanish Keloth --- extensions/kakao/clawdbot.plugin.json | 11 + extensions/kakao/index.ts | 20 + extensions/kakao/package.json | 33 ++ extensions/kakao/src/accounts.ts | 71 ++++ extensions/kakao/src/api.ts | 234 ++++++++++++ extensions/kakao/src/channel.ts | 380 +++++++++++++++++++ extensions/kakao/src/config-schema.ts | 23 ++ extensions/kakao/src/monitor.ts | 514 ++++++++++++++++++++++++++ extensions/kakao/src/probe.ts | 46 +++ extensions/kakao/src/proxy.ts | 15 + extensions/kakao/src/runtime.ts | 14 + extensions/kakao/src/send.ts | 127 +++++++ extensions/kakao/src/status-issues.ts | 46 +++ extensions/kakao/src/token.ts | 55 +++ extensions/kakao/src/types.ts | 40 ++ 15 files changed, 1629 insertions(+) create mode 100644 extensions/kakao/clawdbot.plugin.json create mode 100644 extensions/kakao/index.ts create mode 100644 extensions/kakao/package.json create mode 100644 extensions/kakao/src/accounts.ts create mode 100644 extensions/kakao/src/api.ts create mode 100644 extensions/kakao/src/channel.ts create mode 100644 extensions/kakao/src/config-schema.ts create mode 100644 extensions/kakao/src/monitor.ts create mode 100644 extensions/kakao/src/probe.ts create mode 100644 extensions/kakao/src/proxy.ts create mode 100644 extensions/kakao/src/runtime.ts create mode 100644 extensions/kakao/src/send.ts create mode 100644 extensions/kakao/src/status-issues.ts create mode 100644 extensions/kakao/src/token.ts create mode 100644 extensions/kakao/src/types.ts diff --git a/extensions/kakao/clawdbot.plugin.json b/extensions/kakao/clawdbot.plugin.json new file mode 100644 index 000000000..fc1f1c079 --- /dev/null +++ b/extensions/kakao/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "kakao", + "channels": [ + "kakao" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/kakao/index.ts b/extensions/kakao/index.ts new file mode 100644 index 000000000..cba240001 --- /dev/null +++ b/extensions/kakao/index.ts @@ -0,0 +1,20 @@ +import type { MoltbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { kakaoDock, kakaoPlugin } from "./src/channel.js"; +import { handleKakaoCallbackRequest } from "./src/monitor.js"; +import { setKakaoRuntime } from "./src/runtime.js"; + +const plugin = { + id: "kakao", + name: "KakaoWork", + description: "KakaoWork channel plugin (Bot API)", + configSchema: emptyPluginConfigSchema(), + register(api: MoltbotPluginApi) { + setKakaoRuntime(api.runtime); + api.registerChannel({ plugin: kakaoPlugin, dock: kakaoDock }); + api.registerHttpHandler(handleKakaoCallbackRequest); + }, +}; + +export default plugin; diff --git a/extensions/kakao/package.json b/extensions/kakao/package.json new file mode 100644 index 000000000..2ad9796e3 --- /dev/null +++ b/extensions/kakao/package.json @@ -0,0 +1,33 @@ +{ + "name": "@moltbot/kakao", + "version": "2026.1.28", + "type": "module", + "description": "Moltbot KakaoWork channel plugin", + "moltbot": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "kakao", + "label": "KakaoWork", + "selectionLabel": "KakaoWork (Bot API)", + "docsPath": "/channels/kakao", + "docsLabel": "kakao", + "blurb": "Korea-focused enterprise messaging with KakaoWork Bot API.", + "aliases": [ + "kw" + ], + "order": 85, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@moltbot/kakao", + "localPath": "extensions/kakao", + "defaultChoice": "npm" + } + }, + "dependencies": { + "moltbot": "workspace:*", + "undici": "7.19.0" + } +} diff --git a/extensions/kakao/src/accounts.ts b/extensions/kakao/src/accounts.ts new file mode 100644 index 000000000..20ac3bee2 --- /dev/null +++ b/extensions/kakao/src/accounts.ts @@ -0,0 +1,71 @@ +import type { MoltbotConfig } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; + +import type { ResolvedKakaoAccount, KakaoAccountConfig, KakaoConfig } from "./types.js"; +import { resolveKakaoToken } from "./token.js"; + +function listConfiguredAccountIds(cfg: MoltbotConfig): string[] { + const accounts = (cfg.channels?.kakao as KakaoConfig | undefined)?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listKakaoAccountIds(cfg: MoltbotConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultKakaoAccountId(cfg: MoltbotConfig): string { + const kakaoConfig = cfg.channels?.kakao as KakaoConfig | undefined; + if (kakaoConfig?.defaultAccount?.trim()) return kakaoConfig.defaultAccount.trim(); + const ids = listKakaoAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: MoltbotConfig, + accountId: string, +): KakaoAccountConfig | undefined { + const accounts = (cfg.channels?.kakao as KakaoConfig | undefined)?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + return accounts[accountId] as KakaoAccountConfig | undefined; +} + +function mergeKakaoAccountConfig(cfg: MoltbotConfig, accountId: string): KakaoAccountConfig { + const raw = (cfg.channels?.kakao ?? {}) as KakaoConfig; + const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +export function resolveKakaoAccount(params: { + cfg: MoltbotConfig; + accountId?: string | null; +}): ResolvedKakaoAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = (params.cfg.channels?.kakao as KakaoConfig | undefined)?.enabled !== false; + const merged = mergeKakaoAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const tokenResolution = resolveKakaoToken( + params.cfg.channels?.kakao as KakaoConfig | undefined, + accountId, + ); + + return { + accountId, + name: merged.name?.trim() || undefined, + enabled, + appKey: tokenResolution.token, + tokenSource: tokenResolution.source, + config: merged, + }; +} + +export function listEnabledKakaoAccounts(cfg: MoltbotConfig): ResolvedKakaoAccount[] { + return listKakaoAccountIds(cfg) + .map((accountId) => resolveKakaoAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/kakao/src/api.ts b/extensions/kakao/src/api.ts new file mode 100644 index 000000000..68716ef42 --- /dev/null +++ b/extensions/kakao/src/api.ts @@ -0,0 +1,234 @@ +/** + * KakaoWork Bot API client + * @see https://docs.kakaoi.ai/kakao_work/webapireference/ + */ + +const KAKAO_API_BASE = "https://api.kakaowork.com/v1"; + +export type KakaoFetch = (input: string, init?: RequestInit) => Promise; + +export type KakaoApiResponse = { + success: boolean; + error?: { + code: string; + message: string; + }; +} & T; + +export type KakaoBotInfo = { + title: string; + status: "activated" | "deactivated"; +}; + +export type KakaoConversation = { + id: string; + type: "dm" | "group"; + users_count: number; + avatar_url?: string; + name?: string; +}; + +export type KakaoMessage = { + id: string; + text: string; + user_id: number; + conversation_id: number; + send_time: string; + update_time: string; + blocks?: KakaoBlock[]; +}; + +export type KakaoBlock = { + type: string; + text?: string; + markdown?: boolean; + inlines?: KakaoInline[]; +}; + +export type KakaoInline = { + type: string; + text?: string; + bold?: boolean; +}; + +export type KakaoUser = { + id: number; + name: string; + email?: string; + department?: string; + position?: string; + avatar_url?: string; +}; + +export type KakaoReactiveEvent = { + type: "submit_action" | "request_modal" | "submission"; + action_time: string; + message?: { + id: string; + text: string; + user_id: number; + conversation_id: number; + blocks?: KakaoBlock[]; + }; + react_user_id: number; + action_name?: string; + value?: string; + actions?: Record; +}; + +export class KakaoApiError extends Error { + constructor( + message: string, + public readonly errorCode?: string, + public readonly description?: string, + ) { + super(message); + this.name = "KakaoApiError"; + } +} + +/** + * Call the KakaoWork API + */ +export async function callKakaoApi( + method: string, + appKey: string, + body?: Record, + options?: { timeoutMs?: number; fetch?: KakaoFetch; httpMethod?: "GET" | "POST" }, +): Promise> { + const url = `${KAKAO_API_BASE}/${method}`; + const controller = new AbortController(); + const timeoutId = options?.timeoutMs + ? setTimeout(() => controller.abort(), options.timeoutMs) + : undefined; + const fetcher = options?.fetch ?? fetch; + const httpMethod = options?.httpMethod ?? "POST"; + + try { + const response = await fetcher(url, { + method: httpMethod, + headers: { + "Authorization": `Bearer ${appKey}`, + "Content-Type": "application/json", + }, + body: httpMethod === "POST" && body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + const data = (await response.json()) as KakaoApiResponse; + + if (!data.success) { + throw new KakaoApiError( + data.error?.message ?? `KakaoWork API error: ${method}`, + data.error?.code, + data.error?.message, + ); + } + + return data; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +/** + * Get bot info + */ +export async function getBotInfo( + appKey: string, + timeoutMs?: number, + fetcher?: KakaoFetch, +): Promise> { + return callKakaoApi<{ info: KakaoBotInfo }>("bots.info", appKey, undefined, { + timeoutMs, + fetch: fetcher, + httpMethod: "GET", + }); +} + +/** + * Open or get existing conversation with a user + */ +export async function openConversation( + appKey: string, + userId: number, + fetcher?: KakaoFetch, +): Promise> { + return callKakaoApi<{ conversation: KakaoConversation }>( + "conversations.open", + appKey, + { user_id: userId }, + { fetch: fetcher }, + ); +} + +/** + * List bot conversations + */ +export async function listConversations( + appKey: string, + params?: { cursor?: string; limit?: number }, + fetcher?: KakaoFetch, +): Promise> { + return callKakaoApi<{ conversations: KakaoConversation[]; cursor?: string }>( + "conversations.list", + appKey, + params, + { fetch: fetcher }, + ); +} + +/** + * Send a text message to a conversation + */ +export async function sendMessage( + appKey: string, + params: { conversation_id: number; text: string; blocks?: KakaoBlock[] }, + fetcher?: KakaoFetch, +): Promise> { + return callKakaoApi<{ message: KakaoMessage }>("messages.send", appKey, params, { + fetch: fetcher, + }); +} + +/** + * Send a message by user email + */ +export async function sendMessageByEmail( + appKey: string, + params: { email: string; text: string; blocks?: KakaoBlock[] }, + fetcher?: KakaoFetch, +): Promise> { + return callKakaoApi<{ message: KakaoMessage }>("messages.send_by_email", appKey, params, { + fetch: fetcher, + }); +} + +/** + * Get user info by ID + */ +export async function getUserInfo( + appKey: string, + userId: number, + fetcher?: KakaoFetch, +): Promise> { + return callKakaoApi<{ user: KakaoUser }>( + "users.info", + appKey, + { user_id: userId }, + { fetch: fetcher }, + ); +} + +/** + * List workspace users + */ +export async function listUsers( + appKey: string, + params?: { cursor?: string; limit?: number }, + fetcher?: KakaoFetch, +): Promise> { + return callKakaoApi<{ users: KakaoUser[]; cursor?: string }>("users.list", appKey, params, { + fetch: fetcher, + }); +} diff --git a/extensions/kakao/src/channel.ts b/extensions/kakao/src/channel.ts new file mode 100644 index 000000000..af6c3d40d --- /dev/null +++ b/extensions/kakao/src/channel.ts @@ -0,0 +1,380 @@ +import type { + ChannelAccountSnapshot, + ChannelDock, + ChannelPlugin, + MoltbotConfig, +} from "clawdbot/plugin-sdk"; +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + setAccountEnabledInConfigSection, +} from "clawdbot/plugin-sdk"; + +import { + listKakaoAccountIds, + resolveDefaultKakaoAccountId, + resolveKakaoAccount, + type ResolvedKakaoAccount, +} from "./accounts.js"; +import { KakaoConfigSchema } from "./config-schema.js"; +import { resolveKakaoProxyFetch } from "./proxy.js"; +import { probeKakao } from "./probe.js"; +import { sendMessageKakao, openAndSendMessageKakao } from "./send.js"; +import { collectKakaoStatusIssues } from "./status-issues.js"; + +const meta = { + id: "kakao", + label: "KakaoWork", + selectionLabel: "KakaoWork (Bot API)", + docsPath: "/channels/kakao", + docsLabel: "kakao", + blurb: "Korea-focused enterprise messaging with KakaoWork Bot API.", + aliases: ["kw"], + order: 85, + quickstartAllowFrom: true, +}; + +function normalizeKakaoMessagingTarget(raw: string): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) return undefined; + return trimmed.replace(/^(kakao|kw):/i, ""); +} + +export const kakaoDock: ChannelDock = { + id: "kakao", + capabilities: { + chatTypes: ["direct"], + media: false, + blockStreaming: true, + }, + outbound: { textChunkLimit: 4000 }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + (resolveKakaoAccount({ cfg: cfg as MoltbotConfig, accountId }).config.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^(kakao|kw):/i, "")) + .map((entry) => entry.toLowerCase()), + }, + groups: { + resolveRequireMention: () => true, + }, + threading: { + resolveReplyToMode: () => "off", + }, +}; + +export const kakaoPlugin: ChannelPlugin = { + id: "kakao", + meta, + capabilities: { + chatTypes: ["direct"], + media: false, + reactions: false, + threads: false, + polls: false, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.kakao"] }, + configSchema: buildChannelConfigSchema(KakaoConfigSchema), + config: { + listAccountIds: (cfg) => listKakaoAccountIds(cfg as MoltbotConfig), + resolveAccount: (cfg, accountId) => resolveKakaoAccount({ cfg: cfg as MoltbotConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultKakaoAccountId(cfg as MoltbotConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as MoltbotConfig, + sectionKey: "kakao", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as MoltbotConfig, + sectionKey: "kakao", + accountId, + clearBaseFields: ["appKey", "keyFile", "name"], + }), + isConfigured: (account) => Boolean(account.appKey?.trim()), + describeAccount: (account): ChannelAccountSnapshot => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.appKey?.trim()), + tokenSource: account.tokenSource, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveKakaoAccount({ cfg: cfg as MoltbotConfig, accountId }).config.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^(kakao|kw):/i, "")) + .map((entry) => entry.toLowerCase()), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + (cfg as MoltbotConfig).channels?.kakao?.accounts?.[resolvedAccountId], + ); + const basePath = useAccountPath + ? `channels.kakao.accounts.${resolvedAccountId}.` + : "channels.kakao."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("kakao"), + normalizeEntry: (raw) => raw.replace(/^(kakao|kw):/i, ""), + }; + }, + }, + groups: { + resolveRequireMention: () => true, + }, + threading: { + resolveReplyToMode: () => "off", + }, + messaging: { + normalizeTarget: normalizeKakaoMessagingTarget, + targetResolver: { + looksLikeId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) return false; + return /^\d{3,}$/.test(trimmed); + }, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, accountId, query, limit }) => { + const account = resolveKakaoAccount({ cfg: cfg as MoltbotConfig, accountId }); + const q = query?.trim().toLowerCase() || ""; + const peers = Array.from( + new Set( + (account.config.allowFrom ?? []) + .map((entry) => String(entry).trim()) + .filter((entry) => Boolean(entry) && entry !== "*") + .map((entry) => entry.replace(/^(kakao|kw):/i, "")), + ), + ) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "user", id }) as const); + return peers; + }, + listGroups: async () => [], + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as MoltbotConfig, + channelKey: "kakao", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "KAKAOWORK_APP_KEY can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "KakaoWork requires app key or --key-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as MoltbotConfig, + channelKey: "kakao", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: "kakao", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + kakao: { + ...next.channels?.kakao, + enabled: true, + ...(input.useEnv + ? {} + : input.tokenFile + ? { keyFile: input.tokenFile } + : input.token + ? { appKey: input.token } + : {}), + }, + }, + } as MoltbotConfig; + } + return { + ...next, + channels: { + ...next.channels, + kakao: { + ...next.channels?.kakao, + enabled: true, + accounts: { + ...(next.channels?.kakao?.accounts ?? {}), + [accountId]: { + ...(next.channels?.kakao?.accounts?.[accountId] ?? {}), + enabled: true, + ...(input.tokenFile + ? { keyFile: input.tokenFile } + : input.token + ? { appKey: input.token } + : {}), + }, + }, + }, + }, + } as MoltbotConfig; + }, + }, + pairing: { + idLabel: "kakaoUserId", + normalizeAllowEntry: (entry) => entry.replace(/^(kakao|kw):/i, ""), + notifyApproval: async ({ cfg, id }) => { + const account = resolveKakaoAccount({ cfg: cfg as MoltbotConfig }); + if (!account.appKey) throw new Error("KakaoWork app key not configured"); + await openAndSendMessageKakao(id, PAIRING_APPROVED_MESSAGE, { appKey: account.appKey }); + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => { + if (!text) return []; + if (limit <= 0 || text.length <= limit) return [text]; + const chunks: string[] = []; + let remaining = text; + while (remaining.length > limit) { + const window = remaining.slice(0, limit); + const lastNewline = window.lastIndexOf("\n"); + const lastSpace = window.lastIndexOf(" "); + let breakIdx = lastNewline > 0 ? lastNewline : lastSpace; + if (breakIdx <= 0) breakIdx = limit; + const rawChunk = remaining.slice(0, breakIdx); + const chunk = rawChunk.trimEnd(); + if (chunk.length > 0) chunks.push(chunk); + const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); + const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); + remaining = remaining.slice(nextStart).trimStart(); + } + if (remaining.length) chunks.push(remaining); + return chunks; + }, + chunkerMode: "text", + textChunkLimit: 4000, + sendText: async ({ to, text, accountId, cfg }) => { + const result = await sendMessageKakao(to, text, { + accountId: accountId ?? undefined, + cfg: cfg as MoltbotConfig, + }); + return { + channel: "kakao", + ok: result.ok, + messageId: result.messageId ?? "", + error: result.error ? new Error(result.error) : undefined, + }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: collectKakaoStatusIssues, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + tokenSource: snapshot.tokenSource ?? "none", + running: snapshot.running ?? false, + mode: snapshot.mode ?? null, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => + probeKakao(account.appKey, timeoutMs, resolveKakaoProxyFetch(account.config.proxy)), + buildAccountSnapshot: ({ account, runtime }) => { + const configured = Boolean(account.appKey?.trim()); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + tokenSource: account.tokenSource, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + mode: account.config.callbackUrl ? "callback" : "passive", + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + dmPolicy: account.config.dmPolicy ?? "pairing", + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const appKey = account.appKey.trim(); + let kakaoBotLabel = ""; + const fetcher = resolveKakaoProxyFetch(account.config.proxy); + try { + const probe = await probeKakao(appKey, 2500, fetcher); + const name = probe.ok ? probe.bot?.title?.trim() : null; + if (name) kakaoBotLabel = ` (${name})`; + ctx.setStatus({ + accountId: account.accountId, + bot: probe.bot, + }); + } catch { + // ignore probe errors + } + ctx.log?.info(`[${account.accountId}] starting provider${kakaoBotLabel}`); + const { monitorKakaoProvider } = await import("./monitor.js"); + return monitorKakaoProvider({ + appKey, + account, + config: ctx.cfg as MoltbotConfig, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + callbackUrl: account.config.callbackUrl, + callbackPath: account.config.callbackPath, + fetcher, + statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + }); + }, + }, +}; diff --git a/extensions/kakao/src/config-schema.ts b/extensions/kakao/src/config-schema.ts new file mode 100644 index 000000000..0882dd595 --- /dev/null +++ b/extensions/kakao/src/config-schema.ts @@ -0,0 +1,23 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +const kakaoAccountSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, + appKey: z.string().optional(), + keyFile: z.string().optional(), + callbackUrl: z.string().optional(), + callbackPath: z.string().optional(), + dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + allowFrom: z.array(allowFromEntry).optional(), + mediaMaxMb: z.number().optional(), + proxy: z.string().optional(), +}); + +export const KakaoConfigSchema = kakaoAccountSchema.extend({ + accounts: z.object({}).catchall(kakaoAccountSchema).optional(), + defaultAccount: z.string().optional(), +}); diff --git a/extensions/kakao/src/monitor.ts b/extensions/kakao/src/monitor.ts new file mode 100644 index 000000000..230b023a3 --- /dev/null +++ b/extensions/kakao/src/monitor.ts @@ -0,0 +1,514 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +import type { MoltbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk"; + +import type { ResolvedKakaoAccount } from "./accounts.js"; +import { + sendMessage, + getUserInfo, + type KakaoFetch, + type KakaoReactiveEvent, +} from "./api.js"; +import { resolveKakaoProxyFetch } from "./proxy.js"; +import { getKakaoRuntime } from "./runtime.js"; + +export type KakaoRuntimeEnv = { + log?: (message: string) => void; + error?: (message: string) => void; +}; + +export type KakaoMonitorOptions = { + appKey: string; + account: ResolvedKakaoAccount; + config: MoltbotConfig; + runtime: KakaoRuntimeEnv; + abortSignal: AbortSignal; + callbackUrl?: string; + callbackPath?: string; + fetcher?: KakaoFetch; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +export type KakaoMonitorResult = { + stop: () => void; +}; + +const KAKAO_TEXT_LIMIT = 4000; +const DEFAULT_MEDIA_MAX_MB = 5; + +type KakaoCoreRuntime = ReturnType; + +function logVerbose(core: KakaoCoreRuntime, runtime: KakaoRuntimeEnv, message: string): void { + if (core.logging.shouldLogVerbose()) { + runtime.log?.(`[kakao] ${message}`); + } +} + +function isSenderAllowed(senderId: string, allowFrom: string[]): boolean { + if (allowFrom.includes("*")) return true; + const normalizedSenderId = senderId.toLowerCase(); + return allowFrom.some((entry) => { + const normalized = entry.toLowerCase().replace(/^(kakao|kw):/i, ""); + return normalized === normalizedSenderId; + }); +} + +async function readJsonBody(req: IncomingMessage, maxBytes: number) { + const chunks: Buffer[] = []; + let total = 0; + return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { + req.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + resolve({ ok: false, error: "payload too large" }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + resolve({ ok: false, error: "empty payload" }); + return; + } + resolve({ ok: true, value: JSON.parse(raw) as unknown }); + } catch (err) { + resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } + }); + req.on("error", (err) => { + resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + }); + }); +} + +type CallbackTarget = { + appKey: string; + account: ResolvedKakaoAccount; + config: MoltbotConfig; + runtime: KakaoRuntimeEnv; + core: KakaoCoreRuntime; + path: string; + mediaMaxMb: number; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + fetcher?: KakaoFetch; +}; + +const callbackTargets = new Map(); + +function normalizeCallbackPath(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return "/"; + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + if (withSlash.length > 1 && withSlash.endsWith("/")) { + return withSlash.slice(0, -1); + } + return withSlash; +} + +function resolveCallbackPath(callbackPath?: string, callbackUrl?: string): string | null { + const trimmedPath = callbackPath?.trim(); + if (trimmedPath) return normalizeCallbackPath(trimmedPath); + if (callbackUrl?.trim()) { + try { + const parsed = new URL(callbackUrl); + return normalizeCallbackPath(parsed.pathname || "/"); + } catch { + return null; + } + } + return null; +} + +export function registerKakaoCallbackTarget(target: CallbackTarget): () => void { + const key = normalizeCallbackPath(target.path); + const normalizedTarget = { ...target, path: key }; + const existing = callbackTargets.get(key) ?? []; + const next = [...existing, normalizedTarget]; + callbackTargets.set(key, next); + return () => { + const updated = (callbackTargets.get(key) ?? []).filter( + (entry) => entry !== normalizedTarget, + ); + if (updated.length > 0) { + callbackTargets.set(key, updated); + } else { + callbackTargets.delete(key); + } + }; +} + +export async function handleKakaoCallbackRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const url = new URL(req.url ?? "/", "http://localhost"); + const path = normalizeCallbackPath(url.pathname); + const targets = callbackTargets.get(path); + if (!targets || targets.length === 0) return false; + + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.end("Method Not Allowed"); + return true; + } + + const target = targets[0]; + if (!target) { + res.statusCode = 500; + res.end("no target configured"); + return true; + } + + const body = await readJsonBody(req, 1024 * 1024); + if (!body.ok) { + res.statusCode = body.error === "payload too large" ? 413 : 400; + res.end(body.error ?? "invalid payload"); + return true; + } + + const raw = body.value; + const event = + raw && typeof raw === "object" ? (raw as KakaoReactiveEvent) : null; + + if (!event?.type) { + res.statusCode = 400; + res.end("invalid payload"); + return true; + } + + target.statusSink?.({ lastInboundAt: Date.now() }); + processReactiveEvent( + event, + target.appKey, + target.account, + target.config, + target.runtime, + target.core, + target.mediaMaxMb, + target.statusSink, + target.fetcher, + ).catch((err) => { + target.runtime.error?.(`[${target.account.accountId}] KakaoWork callback failed: ${String(err)}`); + }); + + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ success: true })); + return true; +} + +async function processReactiveEvent( + event: KakaoReactiveEvent, + appKey: string, + account: ResolvedKakaoAccount, + config: MoltbotConfig, + runtime: KakaoRuntimeEnv, + core: KakaoCoreRuntime, + _mediaMaxMb: number, + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void, + fetcher?: KakaoFetch, +): Promise { + const { type, message, react_user_id, value } = event; + + if (!message || !react_user_id) return; + + switch (type) { + case "submit_action": + await handleSubmitAction( + event, + appKey, + account, + config, + runtime, + core, + statusSink, + fetcher, + ); + break; + case "request_modal": + case "submission": + logVerbose(core, runtime, `Received ${type} event from user ${react_user_id}`); + break; + } +} + +async function handleSubmitAction( + event: KakaoReactiveEvent, + appKey: string, + account: ResolvedKakaoAccount, + config: MoltbotConfig, + runtime: KakaoRuntimeEnv, + core: KakaoCoreRuntime, + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void, + fetcher?: KakaoFetch, +): Promise { + const { message, react_user_id, action_name, value } = event; + if (!message) return; + + const conversationId = message.conversation_id; + const senderId = String(react_user_id); + const chatId = String(conversationId); + + let senderName: string | undefined; + try { + const userResponse = await getUserInfo(appKey, react_user_id, fetcher); + if (userResponse.success && userResponse.user) { + senderName = userResponse.user.name; + } + } catch { + // ignore user lookup errors + } + + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); + const rawBody = value?.trim() || action_name || ""; + const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized( + rawBody, + config, + ); + const storeAllowFrom = + dmPolicy !== "open" || shouldComputeAuth + ? await core.channel.pairing.readAllowFromStore("kakao").catch(() => []) + : []; + const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]; + const useAccessGroups = config.commands?.useAccessGroups !== false; + const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom); + const commandAuthorized = shouldComputeAuth + ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }], + }) + : undefined; + + if (dmPolicy === "disabled") { + logVerbose(core, runtime, `Blocked kakao DM from ${senderId} (dmPolicy=disabled)`); + return; + } + + if (dmPolicy !== "open") { + const allowed = senderAllowedForCommands; + + if (!allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "kakao", + id: senderId, + meta: { name: senderName ?? undefined }, + }); + + if (created) { + logVerbose(core, runtime, `kakao pairing request sender=${senderId}`); + try { + await sendMessage( + appKey, + { + conversation_id: conversationId, + text: core.channel.pairing.buildPairingReply({ + channel: "kakao", + idLine: `Your KakaoWork user id: ${senderId}`, + code, + }), + }, + fetcher, + ); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerbose( + core, + runtime, + `kakao pairing reply failed for ${senderId}: ${String(err)}`, + ); + } + } + } else { + logVerbose( + core, + runtime, + `Blocked unauthorized kakao sender ${senderId} (dmPolicy=${dmPolicy})`, + ); + } + return; + } + } + + const route = core.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "kakao", + accountId: account.accountId, + peer: { + kind: "dm", + id: chatId, + }, + }); + + const fromLabel = senderName || `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "KakaoWork", + from: fromLabel, + timestamp: event.action_time ? new Date(event.action_time).getTime() : undefined, + previousTimestamp, + envelope: envelopeOptions, + body: rawBody, + }); + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: rawBody, + CommandBody: rawBody, + From: `kakao:${senderId}`, + To: `kakao:${chatId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: "direct", + ConversationLabel: fromLabel, + SenderName: senderName || undefined, + SenderId: senderId, + CommandAuthorized: commandAuthorized, + Provider: "kakao", + Surface: "kakaowork", + MessageSid: message.id, + OriginatingChannel: "kakao", + OriginatingTo: `kakao:${chatId}`, + }); + + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + runtime.error?.(`kakao: failed updating session meta: ${String(err)}`); + }, + }); + + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "kakao", + accountId: account.accountId, + }); + + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + deliver: async (payload) => { + await deliverKakaoReply({ + payload, + appKey, + conversationId, + runtime, + core, + config, + accountId: account.accountId, + statusSink, + fetcher, + tableMode, + }); + }, + onError: (err, info) => { + runtime.error?.(`[${account.accountId}] KakaoWork ${info.kind} reply failed: ${String(err)}`); + }, + }, + }); +} + +async function deliverKakaoReply(params: { + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + appKey: string; + conversationId: number; + runtime: KakaoRuntimeEnv; + core: KakaoCoreRuntime; + config: MoltbotConfig; + accountId?: string; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + fetcher?: KakaoFetch; + tableMode?: MarkdownTableMode; +}): Promise { + const { payload, appKey, conversationId, runtime, core, config, accountId, statusSink, fetcher } = + params; + const tableMode = params.tableMode ?? "code"; + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + + if (text) { + const chunkMode = core.channel.text.resolveChunkMode(config, "kakao", accountId); + const chunks = core.channel.text.chunkMarkdownTextWithMode( + text, + KAKAO_TEXT_LIMIT, + chunkMode, + ); + for (const chunk of chunks) { + try { + await sendMessage(appKey, { conversation_id: conversationId, text: chunk }, fetcher); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`KakaoWork message send failed: ${String(err)}`); + } + } + } +} + +export async function monitorKakaoProvider( + options: KakaoMonitorOptions, +): Promise { + const { + appKey, + account, + config, + runtime, + abortSignal, + callbackUrl, + callbackPath, + statusSink, + fetcher: fetcherOverride, + } = options; + + const core = getKakaoRuntime(); + const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; + const fetcher = fetcherOverride ?? resolveKakaoProxyFetch(account.config.proxy); + + let stopped = false; + const stopHandlers: Array<() => void> = []; + + const stop = () => { + stopped = true; + for (const handler of stopHandlers) { + handler(); + } + }; + + const path = resolveCallbackPath(callbackPath, callbackUrl); + if (!path) { + runtime.log?.(`[${account.accountId}] KakaoWork running without callback (reactive events disabled)`); + return { stop }; + } + + if (callbackUrl && !callbackUrl.startsWith("https://")) { + runtime.error?.(`[${account.accountId}] KakaoWork callback URL should use HTTPS`); + } + + const unregister = registerKakaoCallbackTarget({ + appKey, + account, + config, + runtime, + core, + path, + statusSink: (patch) => statusSink?.(patch), + mediaMaxMb: effectiveMediaMaxMb, + fetcher, + }); + stopHandlers.push(unregister); + + return { stop }; +} diff --git a/extensions/kakao/src/probe.ts b/extensions/kakao/src/probe.ts new file mode 100644 index 000000000..4173c19fd --- /dev/null +++ b/extensions/kakao/src/probe.ts @@ -0,0 +1,46 @@ +import { getBotInfo, KakaoApiError, type KakaoBotInfo, type KakaoFetch } from "./api.js"; + +export type KakaoProbeResult = { + ok: boolean; + bot?: KakaoBotInfo; + error?: string; + elapsedMs: number; +}; + +export async function probeKakao( + appKey: string, + timeoutMs = 5000, + fetcher?: KakaoFetch, +): Promise { + if (!appKey?.trim()) { + return { ok: false, error: "No app key provided", elapsedMs: 0 }; + } + + const startTime = Date.now(); + + try { + const response = await getBotInfo(appKey.trim(), timeoutMs, fetcher); + const elapsedMs = Date.now() - startTime; + + if (response.success && response.info) { + return { ok: true, bot: response.info, elapsedMs }; + } + + return { ok: false, error: "Invalid response from KakaoWork API", elapsedMs }; + } catch (err) { + const elapsedMs = Date.now() - startTime; + + if (err instanceof KakaoApiError) { + return { ok: false, error: err.description ?? err.message, elapsedMs }; + } + + if (err instanceof Error) { + if (err.name === "AbortError") { + return { ok: false, error: `Request timed out after ${timeoutMs}ms`, elapsedMs }; + } + return { ok: false, error: err.message, elapsedMs }; + } + + return { ok: false, error: String(err), elapsedMs }; + } +} diff --git a/extensions/kakao/src/proxy.ts b/extensions/kakao/src/proxy.ts new file mode 100644 index 000000000..d7afeefb3 --- /dev/null +++ b/extensions/kakao/src/proxy.ts @@ -0,0 +1,15 @@ +import { ProxyAgent } from "undici"; + +import type { KakaoFetch } from "./api.js"; + +export function resolveKakaoProxyFetch(proxyUrl?: string): KakaoFetch | undefined { + if (!proxyUrl?.trim()) return undefined; + + const agent = new ProxyAgent(proxyUrl); + + return async (input: string, init?: RequestInit): Promise => { + const { default: nodeFetch } = await import("node-fetch"); + // @ts-expect-error node-fetch dispatcher option + return nodeFetch(input, { ...init, dispatcher: agent }) as unknown as Response; + }; +} diff --git a/extensions/kakao/src/runtime.ts b/extensions/kakao/src/runtime.ts new file mode 100644 index 000000000..b5f95c9c0 --- /dev/null +++ b/extensions/kakao/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setKakaoRuntime(next: PluginRuntime): void { + runtime = next; +} + +export function getKakaoRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("KakaoWork runtime not initialized"); + } + return runtime; +} diff --git a/extensions/kakao/src/send.ts b/extensions/kakao/src/send.ts new file mode 100644 index 000000000..93ff79a73 --- /dev/null +++ b/extensions/kakao/src/send.ts @@ -0,0 +1,127 @@ +import type { MoltbotConfig } from "clawdbot/plugin-sdk"; + +import type { KakaoFetch } from "./api.js"; +import { sendMessage, openConversation } from "./api.js"; +import { resolveKakaoAccount } from "./accounts.js"; +import { resolveKakaoProxyFetch } from "./proxy.js"; +import { resolveKakaoToken } from "./token.js"; + +export type KakaoSendOptions = { + appKey?: string; + accountId?: string; + cfg?: MoltbotConfig; + verbose?: boolean; + proxy?: string; +}; + +export type KakaoSendResult = { + ok: boolean; + messageId?: string; + error?: string; +}; + +function resolveSendContext(options: KakaoSendOptions): { + appKey: string; + fetcher?: KakaoFetch; +} { + if (options.cfg) { + const account = resolveKakaoAccount({ + cfg: options.cfg, + accountId: options.accountId, + }); + const appKey = options.appKey || account.appKey; + const proxy = options.proxy ?? account.config.proxy; + return { appKey, fetcher: resolveKakaoProxyFetch(proxy) }; + } + + const appKey = options.appKey ?? resolveKakaoToken(undefined, options.accountId).token; + const proxy = options.proxy; + return { appKey, fetcher: resolveKakaoProxyFetch(proxy) }; +} + +export async function sendMessageKakao( + conversationId: string, + text: string, + options: KakaoSendOptions = {}, +): Promise { + const { appKey, fetcher } = resolveSendContext(options); + + if (!appKey) { + return { ok: false, error: "No KakaoWork app key configured" }; + } + + if (!conversationId?.trim()) { + return { ok: false, error: "No conversation_id provided" }; + } + + const numericId = parseInt(conversationId.trim(), 10); + if (Number.isNaN(numericId)) { + return { ok: false, error: "Invalid conversation_id format" }; + } + + try { + const response = await sendMessage( + appKey, + { + conversation_id: numericId, + text: text.slice(0, 4000), + }, + fetcher, + ); + + if (response.success && response.message) { + return { ok: true, messageId: response.message.id }; + } + + return { ok: false, error: "Failed to send message" }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function openAndSendMessageKakao( + userId: string, + text: string, + options: KakaoSendOptions = {}, +): Promise { + const { appKey, fetcher } = resolveSendContext(options); + + if (!appKey) { + return { ok: false, error: "No KakaoWork app key configured" }; + } + + if (!userId?.trim()) { + return { ok: false, error: "No user_id provided" }; + } + + const numericUserId = parseInt(userId.trim(), 10); + if (Number.isNaN(numericUserId)) { + return { ok: false, error: "Invalid user_id format" }; + } + + try { + const convResponse = await openConversation(appKey, numericUserId, fetcher); + + if (!convResponse.success || !convResponse.conversation) { + return { ok: false, error: "Failed to open conversation" }; + } + + const conversationId = parseInt(convResponse.conversation.id, 10); + const msgResponse = await sendMessage( + appKey, + { + conversation_id: conversationId, + text: text.slice(0, 4000), + }, + fetcher, + ); + + if (msgResponse.success && msgResponse.message) { + return { ok: true, messageId: msgResponse.message.id }; + } + + return { ok: false, error: "Failed to send message" }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/extensions/kakao/src/status-issues.ts b/extensions/kakao/src/status-issues.ts new file mode 100644 index 000000000..58b035624 --- /dev/null +++ b/extensions/kakao/src/status-issues.ts @@ -0,0 +1,46 @@ +import type { ChannelAccountSnapshot, ChannelStatusIssue } from "clawdbot/plugin-sdk"; + +export function collectKakaoStatusIssues( + accounts: ChannelAccountSnapshot[], +): ChannelStatusIssue[] { + const issues: ChannelStatusIssue[] = []; + + for (const account of accounts) { + if (!account.configured) { + issues.push({ + level: "error", + message: `KakaoWork account "${account.accountId}" is not configured (missing app key)`, + accountId: account.accountId, + }); + continue; + } + + if (!account.enabled) { + issues.push({ + level: "warn", + message: `KakaoWork account "${account.accountId}" is disabled`, + accountId: account.accountId, + }); + continue; + } + + if (account.lastError) { + issues.push({ + level: "error", + message: `KakaoWork account "${account.accountId}" error: ${account.lastError}`, + accountId: account.accountId, + }); + } + + const probe = account.probe as { ok?: boolean; error?: string } | undefined; + if (probe && !probe.ok && probe.error) { + issues.push({ + level: "error", + message: `KakaoWork account "${account.accountId}" probe failed: ${probe.error}`, + accountId: account.accountId, + }); + } + } + + return issues; +} diff --git a/extensions/kakao/src/token.ts b/extensions/kakao/src/token.ts new file mode 100644 index 000000000..d39a53bc8 --- /dev/null +++ b/extensions/kakao/src/token.ts @@ -0,0 +1,55 @@ +import { readFileSync } from "node:fs"; + +import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk"; + +import type { KakaoConfig } from "./types.js"; + +export type KakaoTokenResolution = { + token: string; + source: "env" | "config" | "configFile" | "none"; +}; + +export function resolveKakaoToken( + config: KakaoConfig | undefined, + accountId?: string | null, +): KakaoTokenResolution { + const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID; + const isDefaultAccount = resolvedAccountId === DEFAULT_ACCOUNT_ID; + const baseConfig = config; + const accountConfig = + resolvedAccountId !== DEFAULT_ACCOUNT_ID + ? (baseConfig?.accounts?.[resolvedAccountId] as KakaoConfig | undefined) + : undefined; + + if (accountConfig) { + const token = accountConfig.appKey?.trim(); + if (token) return { token, source: "config" }; + const keyFile = accountConfig.keyFile?.trim(); + if (keyFile) { + try { + const fileToken = readFileSync(keyFile, "utf8").trim(); + if (fileToken) return { token: fileToken, source: "configFile" }; + } catch { + // ignore read failures + } + } + } + + if (isDefaultAccount) { + const token = baseConfig?.appKey?.trim(); + if (token) return { token, source: "config" }; + const keyFile = baseConfig?.keyFile?.trim(); + if (keyFile) { + try { + const fileToken = readFileSync(keyFile, "utf8").trim(); + if (fileToken) return { token: fileToken, source: "configFile" }; + } catch { + // ignore read failures + } + } + const envToken = process.env.KAKAOWORK_APP_KEY?.trim(); + if (envToken) return { token: envToken, source: "env" }; + } + + return { token: "", source: "none" }; +} diff --git a/extensions/kakao/src/types.ts b/extensions/kakao/src/types.ts new file mode 100644 index 000000000..aa5f23eee --- /dev/null +++ b/extensions/kakao/src/types.ts @@ -0,0 +1,40 @@ +export type KakaoAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this KakaoWork account. Default: true. */ + enabled?: boolean; + /** Bot App Key from KakaoWork admin console. */ + appKey?: string; + /** Path to file containing the app key. */ + keyFile?: string; + /** Callback URL for receiving reactive events (HTTPS required). */ + callbackUrl?: string; + /** Callback path for the gateway HTTP server (defaults to callback URL path). */ + callbackPath?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; + /** Allowlist for DM senders (KakaoWork user IDs). */ + allowFrom?: Array; + /** Max inbound media size in MB. */ + mediaMaxMb?: number; + /** Proxy URL for API requests. */ + proxy?: string; +}; + +export type KakaoConfig = { + /** Optional per-account KakaoWork configuration (multi-account). */ + accounts?: Record; + /** Default account ID when multiple accounts are configured. */ + defaultAccount?: string; +} & KakaoAccountConfig; + +export type KakaoTokenSource = "env" | "config" | "configFile" | "none"; + +export type ResolvedKakaoAccount = { + accountId: string; + name?: string; + enabled: boolean; + appKey: string; + tokenSource: KakaoTokenSource; + config: KakaoAccountConfig; +};