@kakao-bot/sdk 0.0.0-stage → 0.2.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 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,84 @@
1
- # Temporary Holding Version
1
+ # @kakao-bot/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ## 오류 처리
33
+
34
+ 모든 오류는 `KakaoError`를 상속합니다. 한 번의 `catch`로 전부 받고, `name`이나 `instanceof`로 갈라 봅니다.
35
+
36
+ ```ts
37
+ import { isKakaoError, KakaoApiError, KakaoTimeoutError } from '@kakao-bot/sdk';
38
+
39
+ try {
40
+ await kakao.sendMessage({ roomSlug: 'deploy-alerts', text: '배포 완료', requestId: 'deploy:42' });
41
+ } catch (error) {
42
+ if (!isKakaoError(error)) throw error; // 내 코드의 버그는 그대로 올려보냅니다
43
+ logger.error(error.toJSON()); // 로그 한 줄로 바로 적을 수 있는 평평한 객체
44
+ if (error.retryable) await scheduleRetry('deploy:42', error); // 같은 requestId 로만
45
+ }
46
+ ```
47
+
48
+ | 클래스 | 뜻 | `retryable` |
49
+ |---|---|---|
50
+ | `KakaoApiError` | 허브가 응답했지만 2xx가 아님 | 429·5xx는 `true`, 그 외 4xx는 `false` |
51
+ | `KakaoTransportError` | 허브에 닿지 못함 | `true` |
52
+ | `KakaoTimeoutError` | 제한 시간 초과 (`KakaoTransportError`의 하위) | `true` |
53
+ | `KakaoProtocolError` | 2xx인데 본문이 계약과 다름 | `false` |
54
+
55
+ - **`retryable`은 "다시 보내도 안전한가"이지 "자동으로 재시도하라"가 아닙니다.** SDK는 어떤 경우에도 스스로 재시도하지 않습니다. 재시도할 때는 **반드시 같은 `requestId`** 를 쓰세요 — 새 ID로 바꾸면 중복 발송됩니다. `409 idempotency_conflict`만은 `retryable: false`인데, 이미 접수된 건이라 다시 보내면 방에 두 번 올라가기 때문입니다.
56
+ - `KakaoApiError`는 `status`·`code`·`body`·`retryAfterMs`를 싣습니다. `body`에 허브의 원본 응답이 그대로 들어 있어 `402`의 `upgrade` 링크나 `429`의 `scope`(room/global/test)를 읽을 수 있습니다.
57
+ - `code`는 알려진 코드가 자동완성되지만 임의 문자열도 허용합니다 — 허브가 코드를 추가해도 타입이 깨지지 않습니다. 목록은 `KAKAO_ERROR_CODES`.
58
+ - `retryAfterMs`는 본문 `retry_after_ms`를 우선 쓰고, 없으면 `Retry-After` 헤더(초 또는 HTTP-date)를 읽습니다.
59
+ - 전송 실패는 원인을 표준 `Error.cause`로 물고 옵니다.
60
+ - **호출자가 `signal`로 취소하면 `signal.reason`이 그대로 올라옵니다** — `fetch`와 같은 동작이라 기존 `AbortError` 처리가 그대로 먹습니다. SDK 오류로 감싸지 않습니다.
61
+ - `isKakaoError()`는 SDK가 중복 설치돼 `instanceof`가 조용히 실패하는 환경에서도 동작합니다.
62
+ - 각 메서드의 두 번째 인자에 `{ signal }`을 넣어 취소할 수 있습니다.
63
+ - `fetch`와 `timeoutMs`(기본 10초)를 생성자에서 주입할 수 있습니다.
64
+
65
+ ## 릴리스
66
+
67
+ ```sh
68
+ pnpm --filter @kakao-bot/sdk build
69
+ pnpm --filter @kakao-bot/sdk pack --pack-destination /tmp/kakao-sdk-release
70
+ ```
71
+
72
+ `packages/sdk/package.json`의 `version`을 올려 `main`에 머지하면 GitHub Actions(`publish-sdk.yml`)가 npm에 없는 버전인지 확인한 뒤 테스트하고 게시하며, `sdk-v<version>` 태그와 GitHub Release를 자동 생성합니다. 이미 게시된 버전이면 아무것도 하지 않습니다. 저장소 시크릿 `NPM_TOKEN`(Granular Access Token, `@kakao-bot` 스코프 쓰기)이 필요합니다.
73
+
74
+ ## 키 권한
75
+
76
+ 대시보드에서 키 이름과 허용 작업을 선택합니다. `listRooms()`는 `rooms:read`, `sendMessage()`는 `messages:send`가 필요하며 부족하면 `403 insufficient_scope`가 반환됩니다. 키 권한은 서비스에 연결된 방의 범위를 넓히지 않습니다. 기존 키는 두 권한을 유지합니다. 권한을 바꾸려면 새 키를 발급하고 연동을 교체한 뒤 이전 키를 폐기하세요.
77
+
78
+ ## 실제 발송 없는 테스트 키
79
+
80
+ 대시보드에서 키 환경을 **테스트**로 선택하세요. 같은 SDK·API 주소를 사용하며 서버가 키에 저장된 환경을 판별합니다. 클라이언트 요청으로 운영 키로 전환할 수 없습니다. 성공 응답은 HTTP 200, `test: true`, `queued: false`입니다. 입력값·권한·활성 방 연결을 검증하지만 폰 전달, 운영 발송 제한, 월 한도는 검증하지 않습니다. 테스트는 발송 큐·메시지 로그·과금 사용량을 생성하지 않으며 서비스당 분당 60회로 제한됩니다. 실제 방 목록과 연결 설정을 사용하는 검증 모드이며 별도 데이터 샌드박스는 아닙니다.
81
+
82
+ 테스트 요청 ID는 저장하지 않습니다. 같은 ID의 테스트를 반복해도 매번 검증하며, 이후 운영 키에서 같은 ID로 실제 발송할 수 있습니다. 운영 전환은 운영 키를 새로 발급해 서버 환경변수를 교체하세요. 기존 키는 운영 키로 유지됩니다.
83
+
84
+ 동일 서비스에서 이미 접수한 `requestId`의 방 ID 또는 본문을 바꾸면 `409 idempotency_conflict`를 반환합니다. 동일한 방·본문은 중복으로 처리하며 추가 큐/사용량을 만들지 않습니다. slug/방 표시명 변경은 고정 방 ID가 같으면 충돌로 보지 않습니다. 기존 기록 중 본문 검증값이 없는 건은 방 변경만 검출하고 재발송하지 않습니다. 검증값은 서버 비밀키로 보호하므로 비밀키 교체 후 이전 요청의 재시도는 충돌할 수 있습니다.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 허브 호출이 실패했을 때 던지는 것들.
3
+ *
4
+ * 설계는 널리 쓰이는 SDK 들의 합의를 그대로 따른다 — 공통 베이스 클래스 하나(Stripe 의
5
+ * `StripeError`, AWS SDK v3 의 `ServiceException`, Octokit 의 `RequestError` 와 같은 자리),
6
+ * 원본 응답 보존(Stripe `.raw`), 재시도 가능 여부 표시(AWS `$retryable`),
7
+ * 그리고 ES2022 표준 `Error.cause` 로 원인 체인.
8
+ *
9
+ * 핵심은 **소비자가 같은 표를 각자 베껴 쓰지 않게 하는 것**이다. "409 는 다시 보내면 안 되고
10
+ * 429 는 보내도 된다" 는 허브 쪽 지식이라, 서비스마다 손으로 옮겨 적으면 언젠가 한 곳이 틀리고
11
+ * 그 대가는 단톡방에 두 번 올라간 공지다.
12
+ */
13
+ /**
14
+ * 중복 설치된 SDK 사이에서도 에러를 식별하기 위한 브랜드.
15
+ *
16
+ * `instanceof` 는 같은 클래스의 사본이 둘 이상 로드되면(pnpm 중첩 설치, 번들러가 ESM/CJS 를
17
+ * 각각 끌어오는 경우) 조용히 false 가 된다. `Symbol.for` 는 realm 전역 레지스트리라 사본이
18
+ * 몇 개든 같은 심볼을 가리킨다. 내부용이지만 d.ts 에 이름이 필요해 export 한다.
19
+ */
20
+ export declare const KAKAO_ERROR_BRAND: unique symbol;
21
+ /** 허브가 돌려주는 오류 코드. 서버 라우트(`src/outbound/routes.ts`)가 정본이다. */
22
+ export declare const KAKAO_ERROR_CODES: readonly ["validation_error", "invalid_room_slug", "unauthorized", "plan_message_limit", "insufficient_scope", "room_not_bound", "room_inactive", "idempotency_conflict", "rate_limited", "kill_switch", "queue_full", "request_failed"];
23
+ export type KakaoErrorCode = (typeof KAKAO_ERROR_CODES)[number];
24
+ /**
25
+ * 알려진 코드는 자동완성으로 뜨되, 허브가 새 코드를 추가해도 타입이 깨지지 않는다.
26
+ * `(string & {})` 는 유니온이 `string` 으로 뭉개지는 것을 막는 관용구다.
27
+ */
28
+ export type KakaoErrorCodeLike = KakaoErrorCode | (string & {});
29
+ /** 로그에 그대로 적기 좋은 평평한 모양. `toJSON()` 의 결과. */
30
+ export interface KakaoErrorJSON {
31
+ name: string;
32
+ message: string;
33
+ retryable: boolean;
34
+ requestId?: string;
35
+ status?: number;
36
+ code?: string;
37
+ retryAfterMs?: number;
38
+ timeoutMs?: number;
39
+ }
40
+ /** 모든 허브 오류의 베이스. `catch` 에서 `isKakaoError(e)` 한 번으로 전부 잡는다. */
41
+ export declare abstract class KakaoError extends Error {
42
+ readonly [KAKAO_ERROR_BRAND]: true;
43
+ /** 서브클래스를 구분하는 판별자. `instanceof` 가 막히는 환경에서도 읽을 수 있다. */
44
+ abstract readonly name: string;
45
+ /**
46
+ * 같은 요청을 다시 보내도 **안전한가**. 자동 재시도 지시가 아니다 —
47
+ * SDK 는 어떤 경우에도 스스로 재시도하지 않는다.
48
+ */
49
+ abstract readonly retryable: boolean;
50
+ readonly requestId?: string;
51
+ constructor(message: string, requestId?: string, options?: {
52
+ cause?: unknown;
53
+ });
54
+ toJSON(): KakaoErrorJSON;
55
+ }
56
+ /**
57
+ * 허브가 응답은 했지만 2xx 가 아니었다.
58
+ *
59
+ * 본문을 통째로 `body` 에 보관한다 — 402 는 `upgrade` 링크를, 429 는 `scope`(test/global/room)를
60
+ * 함께 싣는데, `error` 필드만 빼고 버리면 그 안내가 사라진다.
61
+ */
62
+ export declare class KakaoApiError extends KakaoError {
63
+ readonly name = "KakaoApiError";
64
+ readonly status: number;
65
+ readonly code: KakaoErrorCodeLike;
66
+ readonly retryable: boolean;
67
+ /** 파싱된 응답 본문. JSON 이 아니었으면 `null`. */
68
+ readonly body: unknown;
69
+ /** `Retry-After` 또는 본문 `retry_after_ms` 에서 읽은 대기 시간. 없으면 `undefined`. */
70
+ readonly retryAfterMs?: number;
71
+ constructor(status: number, code: KakaoErrorCodeLike, requestId?: string, options?: {
72
+ body?: unknown;
73
+ retryAfterMs?: number;
74
+ cause?: unknown;
75
+ });
76
+ toJSON(): KakaoErrorJSON;
77
+ }
78
+ /**
79
+ * 응답을 받지 못했다. 허브에 닿기 전에 끊겼다는 뜻이고, 발송 여부는 알 수 없다.
80
+ *
81
+ * 같은 `requestId` 로 다시 보내면 허브가 중복을 막아 주므로 재시도가 안전하다 —
82
+ * **새 id 로 바꿔 보내면 중복 발송된다.**
83
+ */
84
+ export declare class KakaoTransportError extends KakaoError {
85
+ readonly name: "KakaoTransportError" | "KakaoTimeoutError";
86
+ readonly retryable = true;
87
+ constructor(requestId?: string, options?: {
88
+ cause?: unknown;
89
+ message?: string;
90
+ });
91
+ }
92
+ /**
93
+ * 제한 시간 안에 응답이 오지 않았다. `KakaoTransportError` 의 하위라
94
+ * 기존 `catch (e) { if (e instanceof KakaoTransportError) … }` 가 그대로 잡는다.
95
+ */
96
+ export declare class KakaoTimeoutError extends KakaoTransportError {
97
+ readonly name = "KakaoTimeoutError";
98
+ readonly timeoutMs: number;
99
+ constructor(timeoutMs: number, requestId?: string, options?: {
100
+ cause?: unknown;
101
+ });
102
+ toJSON(): KakaoErrorJSON;
103
+ }
104
+ /**
105
+ * 2xx 를 받았지만 본문이 계약과 달랐다. 허브는 받았을 가능성이 크므로 재시도하지 않는다 —
106
+ * 사람이 대시보드 로그를 보고 판단해야 한다.
107
+ */
108
+ export declare class KakaoProtocolError extends KakaoError {
109
+ readonly name = "KakaoProtocolError";
110
+ readonly retryable = false;
111
+ constructor(requestId?: string, options?: {
112
+ cause?: unknown;
113
+ });
114
+ }
115
+ /**
116
+ * 허브 오류인지 판별한다. 중복 설치로 `instanceof` 가 막힌 환경에서도 동작한다.
117
+ */
118
+ export declare function isKakaoError(value: unknown): value is KakaoError;
package/dist/errors.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * 허브 호출이 실패했을 때 던지는 것들.
3
+ *
4
+ * 설계는 널리 쓰이는 SDK 들의 합의를 그대로 따른다 — 공통 베이스 클래스 하나(Stripe 의
5
+ * `StripeError`, AWS SDK v3 의 `ServiceException`, Octokit 의 `RequestError` 와 같은 자리),
6
+ * 원본 응답 보존(Stripe `.raw`), 재시도 가능 여부 표시(AWS `$retryable`),
7
+ * 그리고 ES2022 표준 `Error.cause` 로 원인 체인.
8
+ *
9
+ * 핵심은 **소비자가 같은 표를 각자 베껴 쓰지 않게 하는 것**이다. "409 는 다시 보내면 안 되고
10
+ * 429 는 보내도 된다" 는 허브 쪽 지식이라, 서비스마다 손으로 옮겨 적으면 언젠가 한 곳이 틀리고
11
+ * 그 대가는 단톡방에 두 번 올라간 공지다.
12
+ */
13
+ /**
14
+ * 중복 설치된 SDK 사이에서도 에러를 식별하기 위한 브랜드.
15
+ *
16
+ * `instanceof` 는 같은 클래스의 사본이 둘 이상 로드되면(pnpm 중첩 설치, 번들러가 ESM/CJS 를
17
+ * 각각 끌어오는 경우) 조용히 false 가 된다. `Symbol.for` 는 realm 전역 레지스트리라 사본이
18
+ * 몇 개든 같은 심볼을 가리킨다. 내부용이지만 d.ts 에 이름이 필요해 export 한다.
19
+ */
20
+ export const KAKAO_ERROR_BRAND = Symbol.for("@kakao-bot/sdk.error");
21
+ /** 허브가 돌려주는 오류 코드. 서버 라우트(`src/outbound/routes.ts`)가 정본이다. */
22
+ export const KAKAO_ERROR_CODES = [
23
+ "validation_error",
24
+ "invalid_room_slug",
25
+ "unauthorized",
26
+ "plan_message_limit",
27
+ "insufficient_scope",
28
+ "room_not_bound",
29
+ "room_inactive",
30
+ "idempotency_conflict",
31
+ "rate_limited",
32
+ "kill_switch",
33
+ "queue_full",
34
+ "request_failed",
35
+ ];
36
+ /** 모든 허브 오류의 베이스. `catch` 에서 `isKakaoError(e)` 한 번으로 전부 잡는다. */
37
+ export class KakaoError extends Error {
38
+ [KAKAO_ERROR_BRAND] = true;
39
+ requestId;
40
+ constructor(message, requestId, options) {
41
+ super(message, options);
42
+ this.requestId = requestId;
43
+ }
44
+ toJSON() {
45
+ return {
46
+ name: this.name,
47
+ message: this.message,
48
+ retryable: this.retryable,
49
+ ...(this.requestId === undefined ? {} : { requestId: this.requestId }),
50
+ };
51
+ }
52
+ }
53
+ /**
54
+ * 허브가 응답은 했지만 2xx 가 아니었다.
55
+ *
56
+ * 본문을 통째로 `body` 에 보관한다 — 402 는 `upgrade` 링크를, 429 는 `scope`(test/global/room)를
57
+ * 함께 싣는데, `error` 필드만 빼고 버리면 그 안내가 사라진다.
58
+ */
59
+ export class KakaoApiError extends KakaoError {
60
+ name = "KakaoApiError";
61
+ status;
62
+ code;
63
+ retryable;
64
+ /** 파싱된 응답 본문. JSON 이 아니었으면 `null`. */
65
+ body;
66
+ /** `Retry-After` 또는 본문 `retry_after_ms` 에서 읽은 대기 시간. 없으면 `undefined`. */
67
+ retryAfterMs;
68
+ constructor(status, code, requestId, options) {
69
+ super(`Kakao Bot API returned ${status} (${code})`, requestId, options);
70
+ this.status = status;
71
+ this.code = code;
72
+ this.body = options?.body ?? null;
73
+ this.retryAfterMs = options?.retryAfterMs;
74
+ this.retryable = isRetryableStatus(status, code);
75
+ }
76
+ toJSON() {
77
+ return {
78
+ ...super.toJSON(),
79
+ status: this.status,
80
+ code: this.code,
81
+ ...(this.retryAfterMs === undefined ? {} : { retryAfterMs: this.retryAfterMs }),
82
+ };
83
+ }
84
+ }
85
+ /**
86
+ * 응답을 받지 못했다. 허브에 닿기 전에 끊겼다는 뜻이고, 발송 여부는 알 수 없다.
87
+ *
88
+ * 같은 `requestId` 로 다시 보내면 허브가 중복을 막아 주므로 재시도가 안전하다 —
89
+ * **새 id 로 바꿔 보내면 중복 발송된다.**
90
+ */
91
+ export class KakaoTransportError extends KakaoError {
92
+ name = "KakaoTransportError";
93
+ retryable = true;
94
+ constructor(requestId, options) {
95
+ super(options?.message ??
96
+ "Kakao Bot request did not complete. Delivery state is unknown; retry with the same request ID.", requestId, options);
97
+ }
98
+ }
99
+ /**
100
+ * 제한 시간 안에 응답이 오지 않았다. `KakaoTransportError` 의 하위라
101
+ * 기존 `catch (e) { if (e instanceof KakaoTransportError) … }` 가 그대로 잡는다.
102
+ */
103
+ export class KakaoTimeoutError extends KakaoTransportError {
104
+ name = "KakaoTimeoutError";
105
+ timeoutMs;
106
+ constructor(timeoutMs, requestId, options) {
107
+ super(requestId, {
108
+ ...options,
109
+ message: `Kakao Bot request timed out after ${timeoutMs}ms. Delivery state is unknown; retry with the same request ID.`,
110
+ });
111
+ this.timeoutMs = timeoutMs;
112
+ }
113
+ toJSON() {
114
+ return { ...super.toJSON(), timeoutMs: this.timeoutMs };
115
+ }
116
+ }
117
+ /**
118
+ * 2xx 를 받았지만 본문이 계약과 달랐다. 허브는 받았을 가능성이 크므로 재시도하지 않는다 —
119
+ * 사람이 대시보드 로그를 보고 판단해야 한다.
120
+ */
121
+ export class KakaoProtocolError extends KakaoError {
122
+ name = "KakaoProtocolError";
123
+ retryable = false;
124
+ constructor(requestId, options) {
125
+ super("Kakao Bot returned an unexpected response.", requestId, options);
126
+ }
127
+ }
128
+ /**
129
+ * 응답을 받은 실패가 재시도해도 안전한지.
130
+ *
131
+ * - `idempotency_conflict` — 같은 id 가 다른 내용으로 이미 접수됐다. 다시 보내면 중복이다.
132
+ * - 429 / 5xx — 일시적이다(한도, `kill_switch`, `queue_full` 모두 풀린다).
133
+ * - 그 밖의 4xx — 권한·플랜·본문 문제라 같은 요청을 다시 보내도 같은 답이 온다.
134
+ */
135
+ function isRetryableStatus(status, code) {
136
+ if (code === "idempotency_conflict")
137
+ return false;
138
+ return status === 429 || status >= 500;
139
+ }
140
+ /**
141
+ * 허브 오류인지 판별한다. 중복 설치로 `instanceof` 가 막힌 환경에서도 동작한다.
142
+ */
143
+ export function isKakaoError(value) {
144
+ return (!!value &&
145
+ typeof value === "object" &&
146
+ value[KAKAO_ERROR_BRAND] === true);
147
+ }
@@ -0,0 +1,56 @@
1
+ export type { KakaoErrorCode, KakaoErrorCodeLike, KakaoErrorJSON } from "./errors.js";
2
+ export { isKakaoError, KAKAO_ERROR_BRAND, KAKAO_ERROR_CODES, KakaoApiError, KakaoError, KakaoProtocolError, KakaoTimeoutError, KakaoTransportError, } from "./errors.js";
3
+ export interface KakaoBotOptions {
4
+ /** Hub origin, e.g. https://hub.example.com. Never use the phone's private endpoint. */
5
+ baseUrl: string;
6
+ /** Service API key issued from the dashboard. Keep on your server. */
7
+ apiKey: string;
8
+ timeoutMs?: number;
9
+ fetch?: typeof globalThis.fetch;
10
+ }
11
+ export type SendMessageInput = ({
12
+ room: string;
13
+ roomSlug?: never;
14
+ roomId?: never;
15
+ } | {
16
+ roomSlug: string;
17
+ room?: never;
18
+ roomId?: never;
19
+ } | {
20
+ roomId: string;
21
+ room?: never;
22
+ roomSlug?: never;
23
+ }) & {
24
+ text: string;
25
+ /** Reuse this ID for the same logical send. No automatic retries are performed. */
26
+ requestId?: string;
27
+ };
28
+ export interface SendMessageResult {
29
+ ok: true;
30
+ queued: boolean;
31
+ duplicate?: boolean;
32
+ /** Validation only: no delivery, queue entry, or billable usage. */
33
+ test?: boolean;
34
+ request_id: string;
35
+ room?: string;
36
+ }
37
+ export interface Room {
38
+ id?: string;
39
+ slug?: string | null;
40
+ name: string;
41
+ status: "pending" | "active" | "suspended";
42
+ canReply: boolean;
43
+ }
44
+ export interface RequestOptions {
45
+ signal?: AbortSignal;
46
+ }
47
+ export declare class KakaoBot {
48
+ private readonly baseUrl;
49
+ private readonly apiKey;
50
+ private readonly timeoutMs;
51
+ private readonly fetchFn;
52
+ constructor(options: KakaoBotOptions);
53
+ sendMessage(input: SendMessageInput, options?: RequestOptions): Promise<SendMessageResult>;
54
+ listRooms(options?: RequestOptions): Promise<Room[]>;
55
+ private request;
56
+ }
package/dist/index.js ADDED
@@ -0,0 +1,129 @@
1
+ export { isKakaoError, KAKAO_ERROR_BRAND, KAKAO_ERROR_CODES, KakaoApiError, KakaoError, KakaoProtocolError, KakaoTimeoutError, KakaoTransportError, } from "./errors.js";
2
+ import { KakaoApiError, KakaoProtocolError, KakaoTimeoutError, KakaoTransportError, } from "./errors.js";
3
+ function object(value) {
4
+ return !!value && typeof value === "object" && !Array.isArray(value);
5
+ }
6
+ export class KakaoBot {
7
+ baseUrl;
8
+ apiKey;
9
+ timeoutMs;
10
+ fetchFn;
11
+ constructor(options) {
12
+ const url = new URL(options.baseUrl);
13
+ if (!["http:", "https:"].includes(url.protocol) ||
14
+ url.username ||
15
+ url.password ||
16
+ url.search ||
17
+ url.hash)
18
+ throw new TypeError("baseUrl must be an HTTP(S) URL without credentials, query or fragment.");
19
+ if (!options.apiKey.trim() || /[\r\n]/.test(options.apiKey))
20
+ throw new TypeError("A non-empty service API key is required.");
21
+ this.baseUrl = url.toString().replace(/\/+$/, "");
22
+ this.apiKey = options.apiKey;
23
+ this.timeoutMs = options.timeoutMs ?? 10000;
24
+ if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 120000)
25
+ throw new TypeError("timeoutMs must be between 1 and 120000.");
26
+ this.fetchFn = options.fetch ?? globalThis.fetch;
27
+ if (!this.fetchFn)
28
+ throw new TypeError("A fetch implementation is required.");
29
+ }
30
+ async sendMessage(input, options = {}) {
31
+ if ((input.roomId !== undefined
32
+ ? !input.roomId.trim()
33
+ : input.roomSlug !== undefined
34
+ ? !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(input.roomSlug)
35
+ : !input.room?.trim()) ||
36
+ !input.text.trim() ||
37
+ input.text.length > 4000)
38
+ throw new TypeError("room and text are required; text must be at most 4000 characters.");
39
+ const requestId = input.requestId ?? globalThis.crypto.randomUUID();
40
+ if (!requestId.trim() || requestId.length > 128)
41
+ throw new TypeError("requestId must contain 1 to 128 characters.");
42
+ const data = await this.request(input.roomId !== undefined
43
+ ? `/v1/rooms/id/${encodeURIComponent(input.roomId)}/messages`
44
+ : input.roomSlug !== undefined
45
+ ? `/v1/rooms/${encodeURIComponent(input.roomSlug)}/messages`
46
+ : "/v1/messages", "POST", { room: input.room, msg: input.text, request_id: requestId }, options.signal, requestId);
47
+ if (!object(data) ||
48
+ data.ok !== true ||
49
+ typeof data.queued !== "boolean" ||
50
+ data.request_id !== requestId ||
51
+ (data.test !== undefined && (typeof data.test !== "boolean" || (data.test && data.queued))) ||
52
+ (data.duplicate !== undefined && typeof data.duplicate !== "boolean") ||
53
+ (data.room !== undefined && typeof data.room !== "string"))
54
+ throw new KakaoProtocolError(requestId);
55
+ return data;
56
+ }
57
+ async listRooms(options = {}) {
58
+ const data = await this.request("/v1/rooms", "GET", undefined, options.signal);
59
+ if (!object(data) ||
60
+ !Array.isArray(data.rooms) ||
61
+ !data.rooms.every((room) => object(room) &&
62
+ (room.id === undefined || typeof room.id === "string") &&
63
+ (room.slug === undefined || room.slug === null || typeof room.slug === "string") &&
64
+ typeof room.name === "string" &&
65
+ ["pending", "active", "suspended"].includes(String(room.status)) &&
66
+ typeof room.canReply === "boolean"))
67
+ throw new KakaoProtocolError();
68
+ return data.rooms;
69
+ }
70
+ async request(path, method, body, signal, requestId) {
71
+ const timeout = AbortSignal.timeout(this.timeoutMs);
72
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
73
+ let response;
74
+ let data;
75
+ try {
76
+ response = await this.fetchFn(`${this.baseUrl}${path}`, {
77
+ method,
78
+ headers: {
79
+ Authorization: `Bearer ${this.apiKey}`,
80
+ Accept: "application/json",
81
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
82
+ },
83
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
84
+ signal: combined,
85
+ // Service keys must never be forwarded to a redirected origin.
86
+ redirect: "error",
87
+ });
88
+ data = await response.json().catch(() => null);
89
+ }
90
+ catch (cause) {
91
+ // The caller's own cancellation is not our failure. Rethrow their reason unchanged,
92
+ // the way fetch does, so `AbortError` handling upstream keeps working.
93
+ if (signal?.aborted)
94
+ throw signal.reason;
95
+ if (timeout.aborted)
96
+ throw new KakaoTimeoutError(this.timeoutMs, requestId, { cause });
97
+ throw new KakaoTransportError(requestId, { cause });
98
+ }
99
+ if (!response.ok) {
100
+ const code = object(data) && typeof data.error === "string" && /^[a-z0-9_]{1,80}$/.test(data.error)
101
+ ? data.error
102
+ : "request_failed";
103
+ throw new KakaoApiError(response.status, code, requestId, {
104
+ body: data,
105
+ retryAfterMs: retryAfterMs(response, data),
106
+ });
107
+ }
108
+ return data;
109
+ }
110
+ }
111
+ /**
112
+ * How long to wait before retrying, in milliseconds.
113
+ *
114
+ * The hub sends an exact `retry_after_ms` in the body; `Retry-After` (RFC 9110) is the
115
+ * interoperable fallback and may also arrive from a proxy in front of the hub, in either
116
+ * the delay-seconds or the HTTP-date form.
117
+ */
118
+ function retryAfterMs(response, body) {
119
+ if (object(body) && typeof body.retry_after_ms === "number" && body.retry_after_ms >= 0)
120
+ return Math.round(body.retry_after_ms);
121
+ const header = response.headers.get("retry-after");
122
+ if (!header)
123
+ return undefined;
124
+ const seconds = Number(header);
125
+ if (Number.isFinite(seconds) && seconds >= 0)
126
+ return Math.round(seconds * 1000);
127
+ const at = Date.parse(header);
128
+ return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
129
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@kakao-bot/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.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
  }