@kakao-bot/sdk 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -2
- package/dist/index.d.ts +72 -0
- package/dist/index.js +128 -0
- package/package.json +47 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeongmin Lee
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kakao-bot/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Kakao Bot Hub용 서버 측 TypeScript SDK입니다. 허브 주소는 `https://kakao-api.jeongmin.dev`, 서비스 API 키는 콘솔(`https://tok.jeongmin.dev`) 개발자 메뉴에서 발급합니다.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @kakao-bot/sdk
|
|
7
|
+
# pnpm add @kakao-bot/sdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { KakaoBot, KakaoApiError, KakaoTransportError } from '@kakao-bot/sdk';
|
|
12
|
+
|
|
13
|
+
const kakao = new KakaoBot({
|
|
14
|
+
baseUrl: process.env.KAKAO_HUB_URL ?? 'https://kakao-api.jeongmin.dev',
|
|
15
|
+
apiKey: process.env.KAKAO_SERVICE_API_KEY!,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const rooms = await kakao.listRooms();
|
|
19
|
+
const result = await kakao.sendMessage({
|
|
20
|
+
roomId: process.env.KAKAO_ROOM_ID!,
|
|
21
|
+
text: '배포가 완료되었습니다.',
|
|
22
|
+
requestId: 'deploy:release-2026-10-10',
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Node.js 20 이상, ESM을 지원합니다. 서비스 키는 브라우저 번들에 넣지 마세요. SDK용 서비스 키와 개인 MCP 토큰은 별도입니다.
|
|
27
|
+
|
|
28
|
+
`listRooms()`에서 방의 `id`, `slug`, `canReply`를 확인합니다. `roomId` 대신 `roomSlug: 'deploy-alerts'`도 사용할 수 있습니다. slug는 워크스페이스 안에서 고유하며 변경 시 이전 주소는 사용할 수 없습니다. 고정 ID 연동은 slug 변경 후에도 유지됩니다. 두 방식 모두 해당 API 키의 서비스에 연결된 방만 허용합니다.
|
|
29
|
+
|
|
30
|
+
`queued: true`는 접수이며 실제 전달 성공이 아닙니다. 서버는 서비스별 `requestId`로 중복을 방지합니다. 같은 논리적 발송에는 같은 ID를 사용하세요. **SDK는 POST 또는 GET을 자동 재시도하지 않습니다.** 타임아웃은 이미 접수된 요청일 수 있으므로 새 ID로 재시도하면 중복 발송됩니다. `KakaoTransportError.requestId`로 요청을 식별하고 대시보드 로그를 확인하세요.
|
|
31
|
+
|
|
32
|
+
- `KakaoApiError`: HTTP 상태·오류 코드·요청 ID. `402 plan_message_limit`은 플랜 사용량 제한, `429 rate_limited`는 단기 발송 제한입니다.
|
|
33
|
+
- `KakaoTransportError`: 전송/타임아웃 실패. 발송 결과는 알 수 없습니다.
|
|
34
|
+
- `KakaoProtocolError`: 예상하지 못한 서버 응답.
|
|
35
|
+
- 각 메서드의 두 번째 인자에 `{ signal }`을 넣어 취소할 수 있습니다.
|
|
36
|
+
- `fetch`와 `timeoutMs`를 생성자에서 주입할 수 있습니다.
|
|
37
|
+
|
|
38
|
+
## 릴리스
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pnpm --filter @kakao-bot/sdk build
|
|
42
|
+
pnpm --filter @kakao-bot/sdk pack --pack-destination /tmp/kakao-sdk-release
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`packages/sdk/package.json`의 `version`을 올려 `main`에 머지하면 GitHub Actions(`publish-sdk.yml`)가 npm에 없는 버전인지 확인한 뒤 테스트하고 게시하며, `sdk-v<version>` 태그와 GitHub Release를 자동 생성합니다. 이미 게시된 버전이면 아무것도 하지 않습니다. 저장소 시크릿 `NPM_TOKEN`(Granular Access Token, `@kakao-bot` 스코프 쓰기)이 필요합니다.
|
|
46
|
+
|
|
47
|
+
## 키 권한
|
|
48
|
+
|
|
49
|
+
대시보드에서 키 이름과 허용 작업을 선택합니다. `listRooms()`는 `rooms:read`, `sendMessage()`는 `messages:send`가 필요하며 부족하면 `403 insufficient_scope`가 반환됩니다. 키 권한은 서비스에 연결된 방의 범위를 넓히지 않습니다. 기존 키는 두 권한을 유지합니다. 권한을 바꾸려면 새 키를 발급하고 연동을 교체한 뒤 이전 키를 폐기하세요.
|
|
50
|
+
|
|
51
|
+
## 실제 발송 없는 테스트 키
|
|
52
|
+
|
|
53
|
+
대시보드에서 키 환경을 **테스트**로 선택하세요. 같은 SDK·API 주소를 사용하며 서버가 키에 저장된 환경을 판별합니다. 클라이언트 요청으로 운영 키로 전환할 수 없습니다. 성공 응답은 HTTP 200, `test: true`, `queued: false`입니다. 입력값·권한·활성 방 연결을 검증하지만 폰 전달, 운영 발송 제한, 월 한도는 검증하지 않습니다. 테스트는 발송 큐·메시지 로그·과금 사용량을 생성하지 않으며 서비스당 분당 60회로 제한됩니다. 실제 방 목록과 연결 설정을 사용하는 검증 모드이며 별도 데이터 샌드박스는 아닙니다.
|
|
54
|
+
|
|
55
|
+
테스트 요청 ID는 저장하지 않습니다. 같은 ID의 테스트를 반복해도 매번 검증하며, 이후 운영 키에서 같은 ID로 실제 발송할 수 있습니다. 운영 전환은 운영 키를 새로 발급해 서버 환경변수를 교체하세요. 기존 키는 운영 키로 유지됩니다.
|
|
56
|
+
|
|
57
|
+
동일 서비스에서 이미 접수한 `requestId`의 방 ID 또는 본문을 바꾸면 `409 idempotency_conflict`를 반환합니다. 동일한 방·본문은 중복으로 처리하며 추가 큐/사용량을 만들지 않습니다. slug/방 표시명 변경은 고정 방 ID가 같으면 충돌로 보지 않습니다. 기존 기록 중 본문 검증값이 없는 건은 방 변경만 검출하고 재발송하지 않습니다. 검증값은 서버 비밀키로 보호하므로 비밀키 교체 후 이전 요청의 재시도는 충돌할 수 있습니다.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface KakaoBotOptions {
|
|
2
|
+
/** Hub origin, e.g. https://hub.example.com. Never use the phone's private endpoint. */
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
/** Service API key issued from the dashboard. Keep on your server. */
|
|
5
|
+
apiKey: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
fetch?: typeof globalThis.fetch;
|
|
8
|
+
}
|
|
9
|
+
export type SendMessageInput = ({
|
|
10
|
+
room: string;
|
|
11
|
+
roomSlug?: never;
|
|
12
|
+
roomId?: never;
|
|
13
|
+
} | {
|
|
14
|
+
roomSlug: string;
|
|
15
|
+
room?: never;
|
|
16
|
+
roomId?: never;
|
|
17
|
+
} | {
|
|
18
|
+
roomId: string;
|
|
19
|
+
room?: never;
|
|
20
|
+
roomSlug?: never;
|
|
21
|
+
}) & {
|
|
22
|
+
text: string;
|
|
23
|
+
/** Reuse this ID for the same logical send. No automatic retries are performed. */
|
|
24
|
+
requestId?: string;
|
|
25
|
+
};
|
|
26
|
+
export interface SendMessageResult {
|
|
27
|
+
ok: true;
|
|
28
|
+
queued: boolean;
|
|
29
|
+
duplicate?: boolean;
|
|
30
|
+
/** Validation only: no delivery, queue entry, or billable usage. */
|
|
31
|
+
test?: boolean;
|
|
32
|
+
request_id: string;
|
|
33
|
+
room?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface Room {
|
|
36
|
+
id?: string;
|
|
37
|
+
slug?: string | null;
|
|
38
|
+
name: string;
|
|
39
|
+
status: "pending" | "active" | "suspended";
|
|
40
|
+
canReply: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface RequestOptions {
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
}
|
|
45
|
+
export declare class KakaoApiError extends Error {
|
|
46
|
+
readonly status: number;
|
|
47
|
+
readonly code: string;
|
|
48
|
+
readonly requestId?: string | undefined;
|
|
49
|
+
readonly name = "KakaoApiError";
|
|
50
|
+
constructor(status: number, code: string, requestId?: string | undefined);
|
|
51
|
+
}
|
|
52
|
+
export declare class KakaoTransportError extends Error {
|
|
53
|
+
readonly requestId?: string | undefined;
|
|
54
|
+
readonly name = "KakaoTransportError";
|
|
55
|
+
/** A timed-out send may already have been queued. Never retry with a new request ID. */
|
|
56
|
+
constructor(requestId?: string | undefined);
|
|
57
|
+
}
|
|
58
|
+
export declare class KakaoProtocolError extends Error {
|
|
59
|
+
readonly requestId?: string | undefined;
|
|
60
|
+
readonly name = "KakaoProtocolError";
|
|
61
|
+
constructor(requestId?: string | undefined);
|
|
62
|
+
}
|
|
63
|
+
export declare class KakaoBot {
|
|
64
|
+
private readonly baseUrl;
|
|
65
|
+
private readonly apiKey;
|
|
66
|
+
private readonly timeoutMs;
|
|
67
|
+
private readonly fetchFn;
|
|
68
|
+
constructor(options: KakaoBotOptions);
|
|
69
|
+
sendMessage(input: SendMessageInput, options?: RequestOptions): Promise<SendMessageResult>;
|
|
70
|
+
listRooms(options?: RequestOptions): Promise<Room[]>;
|
|
71
|
+
private request;
|
|
72
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export class KakaoApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
requestId;
|
|
5
|
+
name = "KakaoApiError";
|
|
6
|
+
constructor(status, code, requestId) {
|
|
7
|
+
super(`Kakao Bot API returned ${status} (${code})`);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.requestId = requestId;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class KakaoTransportError extends Error {
|
|
14
|
+
requestId;
|
|
15
|
+
name = "KakaoTransportError";
|
|
16
|
+
/** A timed-out send may already have been queued. Never retry with a new request ID. */
|
|
17
|
+
constructor(requestId) {
|
|
18
|
+
super("Kakao Bot request did not complete. Delivery state is unknown; inspect the dashboard before retrying.");
|
|
19
|
+
this.requestId = requestId;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class KakaoProtocolError extends Error {
|
|
23
|
+
requestId;
|
|
24
|
+
name = "KakaoProtocolError";
|
|
25
|
+
constructor(requestId) {
|
|
26
|
+
super("Kakao Bot returned an unexpected response.");
|
|
27
|
+
this.requestId = requestId;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function object(value) {
|
|
31
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
export class KakaoBot {
|
|
34
|
+
baseUrl;
|
|
35
|
+
apiKey;
|
|
36
|
+
timeoutMs;
|
|
37
|
+
fetchFn;
|
|
38
|
+
constructor(options) {
|
|
39
|
+
const url = new URL(options.baseUrl);
|
|
40
|
+
if (!["http:", "https:"].includes(url.protocol) ||
|
|
41
|
+
url.username ||
|
|
42
|
+
url.password ||
|
|
43
|
+
url.search ||
|
|
44
|
+
url.hash)
|
|
45
|
+
throw new TypeError("baseUrl must be an HTTP(S) URL without credentials, query or fragment.");
|
|
46
|
+
if (!options.apiKey.trim() || /[\r\n]/.test(options.apiKey))
|
|
47
|
+
throw new TypeError("A non-empty service API key is required.");
|
|
48
|
+
this.baseUrl = url.toString().replace(/\/+$/, "");
|
|
49
|
+
this.apiKey = options.apiKey;
|
|
50
|
+
this.timeoutMs = options.timeoutMs ?? 10000;
|
|
51
|
+
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 120000)
|
|
52
|
+
throw new TypeError("timeoutMs must be between 1 and 120000.");
|
|
53
|
+
this.fetchFn = options.fetch ?? globalThis.fetch;
|
|
54
|
+
if (!this.fetchFn)
|
|
55
|
+
throw new TypeError("A fetch implementation is required.");
|
|
56
|
+
}
|
|
57
|
+
async sendMessage(input, options = {}) {
|
|
58
|
+
if ((input.roomId !== undefined
|
|
59
|
+
? !input.roomId.trim()
|
|
60
|
+
: input.roomSlug !== undefined
|
|
61
|
+
? !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(input.roomSlug)
|
|
62
|
+
: !input.room?.trim()) ||
|
|
63
|
+
!input.text.trim() ||
|
|
64
|
+
input.text.length > 4000)
|
|
65
|
+
throw new TypeError("room and text are required; text must be at most 4000 characters.");
|
|
66
|
+
const requestId = input.requestId ?? globalThis.crypto.randomUUID();
|
|
67
|
+
if (!requestId.trim() || requestId.length > 128)
|
|
68
|
+
throw new TypeError("requestId must contain 1 to 128 characters.");
|
|
69
|
+
const data = await this.request(input.roomId !== undefined
|
|
70
|
+
? `/v1/rooms/id/${encodeURIComponent(input.roomId)}/messages`
|
|
71
|
+
: input.roomSlug !== undefined
|
|
72
|
+
? `/v1/rooms/${encodeURIComponent(input.roomSlug)}/messages`
|
|
73
|
+
: "/v1/messages", "POST", { room: input.room, msg: input.text, request_id: requestId }, options.signal, requestId);
|
|
74
|
+
if (!object(data) ||
|
|
75
|
+
data.ok !== true ||
|
|
76
|
+
typeof data.queued !== "boolean" ||
|
|
77
|
+
data.request_id !== requestId ||
|
|
78
|
+
(data.test !== undefined && (typeof data.test !== "boolean" || (data.test && data.queued))) ||
|
|
79
|
+
(data.duplicate !== undefined && typeof data.duplicate !== "boolean") ||
|
|
80
|
+
(data.room !== undefined && typeof data.room !== "string"))
|
|
81
|
+
throw new KakaoProtocolError(requestId);
|
|
82
|
+
return data;
|
|
83
|
+
}
|
|
84
|
+
async listRooms(options = {}) {
|
|
85
|
+
const data = await this.request("/v1/rooms", "GET", undefined, options.signal);
|
|
86
|
+
if (!object(data) ||
|
|
87
|
+
!Array.isArray(data.rooms) ||
|
|
88
|
+
!data.rooms.every((room) => object(room) &&
|
|
89
|
+
(room.id === undefined || typeof room.id === "string") &&
|
|
90
|
+
(room.slug === undefined || room.slug === null || typeof room.slug === "string") &&
|
|
91
|
+
typeof room.name === "string" &&
|
|
92
|
+
["pending", "active", "suspended"].includes(String(room.status)) &&
|
|
93
|
+
typeof room.canReply === "boolean"))
|
|
94
|
+
throw new KakaoProtocolError();
|
|
95
|
+
return data.rooms;
|
|
96
|
+
}
|
|
97
|
+
async request(path, method, body, signal, requestId) {
|
|
98
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
99
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
100
|
+
let response;
|
|
101
|
+
let data;
|
|
102
|
+
try {
|
|
103
|
+
response = await this.fetchFn(`${this.baseUrl}${path}`, {
|
|
104
|
+
method,
|
|
105
|
+
headers: {
|
|
106
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
107
|
+
Accept: "application/json",
|
|
108
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
109
|
+
},
|
|
110
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
111
|
+
signal: combined,
|
|
112
|
+
// Service keys must never be forwarded to a redirected origin.
|
|
113
|
+
redirect: "error",
|
|
114
|
+
});
|
|
115
|
+
data = await response.json().catch(() => null);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new KakaoTransportError(requestId);
|
|
119
|
+
}
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const code = object(data) && typeof data.error === "string" && /^[a-z0-9_]{1,80}$/.test(data.error)
|
|
122
|
+
? data.error
|
|
123
|
+
: "request_failed";
|
|
124
|
+
throw new KakaoApiError(response.status, code, requestId);
|
|
125
|
+
}
|
|
126
|
+
return data;
|
|
127
|
+
}
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kakao-bot/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Typed server-side client for the Kakao Bot Hub API",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Jeongmin Lee",
|
|
7
|
+
"homepage": "https://github.com/froggy1014/kakao-bot/tree/main/packages/sdk#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/froggy1014/kakao-bot.git",
|
|
11
|
+
"directory": "packages/sdk"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/froggy1014/kakao-bot/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"kakao",
|
|
16
|
+
"kakaotalk",
|
|
17
|
+
"bot",
|
|
18
|
+
"messaging",
|
|
19
|
+
"sdk",
|
|
20
|
+
"typescript"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20.3"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"provenance": true
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc -p tsconfig.json",
|
|
48
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
49
|
+
}
|
|
6
50
|
}
|