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:
parent
93c2d65398
commit
24ba96a3de
11
extensions/kakao/clawdbot.plugin.json
Normal file
11
extensions/kakao/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "kakao",
|
||||
"channels": [
|
||||
"kakao"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
20
extensions/kakao/index.ts
Normal file
20
extensions/kakao/index.ts
Normal 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;
|
||||
33
extensions/kakao/package.json
Normal file
33
extensions/kakao/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
71
extensions/kakao/src/accounts.ts
Normal file
71
extensions/kakao/src/accounts.ts
Normal 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
234
extensions/kakao/src/api.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
380
extensions/kakao/src/channel.ts
Normal file
380
extensions/kakao/src/channel.ts
Normal 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 }),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
23
extensions/kakao/src/config-schema.ts
Normal file
23
extensions/kakao/src/config-schema.ts
Normal 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(),
|
||||
});
|
||||
514
extensions/kakao/src/monitor.ts
Normal file
514
extensions/kakao/src/monitor.ts
Normal 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 };
|
||||
}
|
||||
46
extensions/kakao/src/probe.ts
Normal file
46
extensions/kakao/src/probe.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
15
extensions/kakao/src/proxy.ts
Normal file
15
extensions/kakao/src/proxy.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
14
extensions/kakao/src/runtime.ts
Normal file
14
extensions/kakao/src/runtime.ts
Normal 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;
|
||||
}
|
||||
127
extensions/kakao/src/send.ts
Normal file
127
extensions/kakao/src/send.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
46
extensions/kakao/src/status-issues.ts
Normal file
46
extensions/kakao/src/status-issues.ts
Normal 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;
|
||||
}
|
||||
55
extensions/kakao/src/token.ts
Normal file
55
extensions/kakao/src/token.ts
Normal 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" };
|
||||
}
|
||||
40
extensions/kakao/src/types.ts
Normal file
40
extensions/kakao/src/types.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user