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 <hanishkeloth@users.noreply.github.com>
This commit is contained in:
Hanish Keloth 2026-01-28 15:19:45 +09:00
parent 93c2d65398
commit 24ba96a3de
15 changed files with 1629 additions and 0 deletions

View File

@ -0,0 +1,11 @@
{
"id": "kakao",
"channels": [
"kakao"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

20
extensions/kakao/index.ts Normal file
View File

@ -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;

View File

@ -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"
}
}

View File

@ -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);
}

234
extensions/kakao/src/api.ts Normal file
View File

@ -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<Response>;
export type KakaoApiResponse<T = unknown> = {
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<string, string>;
};
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<T = unknown>(
method: string,
appKey: string,
body?: Record<string, unknown>,
options?: { timeoutMs?: number; fetch?: KakaoFetch; httpMethod?: "GET" | "POST" },
): Promise<KakaoApiResponse<T>> {
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<T>;
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<KakaoApiResponse<{ info: KakaoBotInfo }>> {
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<KakaoApiResponse<{ conversation: KakaoConversation }>> {
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<KakaoApiResponse<{ conversations: KakaoConversation[]; cursor?: string }>> {
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<KakaoApiResponse<{ message: KakaoMessage }>> {
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<KakaoApiResponse<{ message: KakaoMessage }>> {
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<KakaoApiResponse<{ user: KakaoUser }>> {
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<KakaoApiResponse<{ users: KakaoUser[]; cursor?: string }>> {
return callKakaoApi<{ users: KakaoUser[]; cursor?: string }>("users.list", appKey, params, {
fetch: fetcher,
});
}

View File

@ -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<ResolvedKakaoAccount> = {
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: "<conversationId or userId>",
},
},
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 }),
});
},
},
};

View File

@ -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(),
});

View File

@ -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<typeof getKakaoRuntime>;
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<string, CallbackTarget[]>();
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<boolean> {
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<void> {
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<void> {
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<void> {
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<KakaoMonitorResult> {
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 };
}

View File

@ -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<KakaoProbeResult> {
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 };
}
}

View File

@ -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<Response> => {
const { default: nodeFetch } = await import("node-fetch");
// @ts-expect-error node-fetch dispatcher option
return nodeFetch(input, { ...init, dispatcher: agent }) as unknown as Response;
};
}

View File

@ -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;
}

View File

@ -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<KakaoSendResult> {
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<KakaoSendResult> {
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) };
}
}

View File

@ -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;
}

View File

@ -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" };
}

View File

@ -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<string | number>;
/** 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<string, KakaoAccountConfig>;
/** 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;
};