kakaotalk-bot 0.1.0__tar.gz
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.
- kakaotalk_bot-0.1.0/.gitignore +8 -0
- kakaotalk_bot-0.1.0/LICENSE +21 -0
- kakaotalk_bot-0.1.0/PKG-INFO +140 -0
- kakaotalk_bot-0.1.0/README.md +111 -0
- kakaotalk_bot-0.1.0/kakaotalk_bot/__init__.py +98 -0
- kakaotalk_bot-0.1.0/kakaotalk_bot/client.py +289 -0
- kakaotalk_bot-0.1.0/kakaotalk_bot/errors.py +76 -0
- kakaotalk_bot-0.1.0/kakaotalk_bot/events.py +108 -0
- kakaotalk_bot-0.1.0/kakaotalk_bot/gateway.py +372 -0
- kakaotalk_bot-0.1.0/kakaotalk_bot/py.typed +0 -0
- kakaotalk_bot-0.1.0/kakaotalk_bot/skill.py +356 -0
- kakaotalk_bot-0.1.0/kakaotalk_bot/types.py +364 -0
- kakaotalk_bot-0.1.0/kakaotalk_bot/webhook.py +32 -0
- kakaotalk_bot-0.1.0/pyproject.toml +49 -0
- kakaotalk_bot-0.1.0/tests/test_smoke.py +218 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jade (punch5545)
|
|
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.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: kakaotalk-bot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: KakaoTalk Bot API client: REST API wrapper, Gateway (WebSocket) client and webhook helpers
|
|
5
|
+
Project-URL: Homepage, https://github.com/punch5545/KakaoTalk-Bot-Wrapper/tree/main/python#readme
|
|
6
|
+
Project-URL: Repository, https://github.com/punch5545/KakaoTalk-Bot-Wrapper
|
|
7
|
+
Project-URL: Issues, https://github.com/punch5545/KakaoTalk-Bot-Wrapper/issues
|
|
8
|
+
Project-URL: Documentation, https://developers.kakao.com/docs/ko/bot/common
|
|
9
|
+
Author: jade (punch5545)
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: bot,chatbot,gateway,kakao,kakaotalk,websocket
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: websockets>=12.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# kakaotalk-bot (Python)
|
|
31
|
+
|
|
32
|
+
KakaoTalk Bot API client for Python 3.10+: REST API wrapper (async + sync), Gateway (WebSocket) client
|
|
33
|
+
and webhook helpers. Depends on `httpx` and `websockets`.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install kakaotalk-bot
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## REST API
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from kakaotalk_bot import (
|
|
43
|
+
KakaoBotClient, KakaoBotSyncClient, KakaoBotApiError,
|
|
44
|
+
text_response, SkillResponseBuilder, basic_card, web_link_button, message_button, mention_tag,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
async with KakaoBotClient(BOT_TOKEN) as client:
|
|
48
|
+
await client.send_callback(callback_token, text_response("처리가 완료되었습니다."))
|
|
49
|
+
await client.send_to_group(bot_group_key, text_response("안녕하세요!"))
|
|
50
|
+
await client.send_to_user(bot_user_key, text_response("안녕하세요!")) # channel chat only
|
|
51
|
+
|
|
52
|
+
commands = await client.get_commands()
|
|
53
|
+
await client.update_commands([{"buttonName": "도움말"}, {"buttonName": "상담원 연결", "description": "상담원과 연결합니다."}])
|
|
54
|
+
await client.update_guide("🤖 저와 함께 이런 것들을 할 수 있어요.")
|
|
55
|
+
|
|
56
|
+
response = (
|
|
57
|
+
SkillResponseBuilder()
|
|
58
|
+
.text(f"안녕하세요 {mention_tag('u1')}!")
|
|
59
|
+
.mention("u1", bot_user_key)
|
|
60
|
+
.add(basic_card("제목", "설명", {"imageUrl": "https://example.com/a.png"},
|
|
61
|
+
[web_link_button("열기", "https://example.com"), message_button("다시")]))
|
|
62
|
+
.quick_reply("도움말")
|
|
63
|
+
.build()
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
await client.send_callback(callback_token, response)
|
|
67
|
+
except KakaoBotApiError as e:
|
|
68
|
+
if e.is_callback_token_error: # -881
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
# Blocking variant with the same methods
|
|
72
|
+
with KakaoBotSyncClient(BOT_TOKEN) as client:
|
|
73
|
+
client.send_to_group(bot_group_key, text_response("hi"))
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Options: `KakaoBotClient(token, base_url=..., timeout=10.0, headers=..., http=httpx.AsyncClient(...))`.
|
|
77
|
+
|
|
78
|
+
## Gateway (WebSocket)
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import asyncio
|
|
82
|
+
from kakaotalk_bot import KakaoBotGateway, DispatchEvent
|
|
83
|
+
|
|
84
|
+
gateway = KakaoBotGateway(
|
|
85
|
+
BOT_TOKEN,
|
|
86
|
+
# url=..., client=..., auto_reconnect=True, initial_backoff=1.0, max_backoff=30.0,
|
|
87
|
+
# heartbeat_margin=5.0, handshake_timeout=15.0,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@gateway.on("ready")
|
|
91
|
+
def on_ready(ready):
|
|
92
|
+
print("bot", ready["bot"]["id"], "session", ready["session_id"])
|
|
93
|
+
|
|
94
|
+
@gateway.on("MESSAGE_CREATE")
|
|
95
|
+
async def on_message(event: DispatchEvent):
|
|
96
|
+
# event.data: {"id", "content", "timestamp", "isChannelChatroom", "botGroupKey", "botUserKey", "callbackToken"?}
|
|
97
|
+
if event.can_reply:
|
|
98
|
+
await event.reply_text(f"echo: {event.content}")
|
|
99
|
+
|
|
100
|
+
@gateway.on("error")
|
|
101
|
+
def on_error(exc):
|
|
102
|
+
print("gateway error:", exc) # fatal close codes: exc.fatal is True
|
|
103
|
+
|
|
104
|
+
asyncio.run(gateway.run()) # blocks; reconnects automatically
|
|
105
|
+
# or: task = await gateway.start() # background task, returns once READY
|
|
106
|
+
# await gateway.close()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Events: `ready`, `resumed`, `dispatch`, `MESSAGE_CREATE`, `ENTRANCE`, `CHAT_JOIN`, `LEAVE`, `AD_REWARD`,
|
|
110
|
+
`close`, `reconnecting`, `heartbeat_timeout`, `error`. Listeners may be sync or async.
|
|
111
|
+
|
|
112
|
+
Session persistence: save `gateway.session` and call `gateway.restore_session(session_id, last_seq)`
|
|
113
|
+
before `run()` to RESUME across process restarts.
|
|
114
|
+
|
|
115
|
+
## Webhook (FastAPI example)
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from fastapi import FastAPI, Request, Response, BackgroundTasks
|
|
119
|
+
from kakaotalk_bot import KakaoBotClient, parse_webhook_event, verify_custom_header
|
|
120
|
+
|
|
121
|
+
client = KakaoBotClient(BOT_TOKEN)
|
|
122
|
+
app = FastAPI()
|
|
123
|
+
|
|
124
|
+
@app.post("/webhook")
|
|
125
|
+
async def webhook(request: Request, background: BackgroundTasks) -> Response:
|
|
126
|
+
if not verify_custom_header(request.headers.get("X-My-Secret"), WEBHOOK_SECRET):
|
|
127
|
+
return Response(status_code=401)
|
|
128
|
+
event = parse_webhook_event(await request.body(), client)
|
|
129
|
+
if event.type == "MESSAGE_CREATE" and event.can_reply:
|
|
130
|
+
background.add_task(event.reply_text, f"echo: {event.content}")
|
|
131
|
+
return Response(status_code=204) # within 1 second
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pip install -e ".[dev]"
|
|
138
|
+
pytest
|
|
139
|
+
mypy kakaotalk_bot
|
|
140
|
+
```
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# kakaotalk-bot (Python)
|
|
2
|
+
|
|
3
|
+
KakaoTalk Bot API client for Python 3.10+: REST API wrapper (async + sync), Gateway (WebSocket) client
|
|
4
|
+
and webhook helpers. Depends on `httpx` and `websockets`.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install kakaotalk-bot
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## REST API
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from kakaotalk_bot import (
|
|
14
|
+
KakaoBotClient, KakaoBotSyncClient, KakaoBotApiError,
|
|
15
|
+
text_response, SkillResponseBuilder, basic_card, web_link_button, message_button, mention_tag,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
async with KakaoBotClient(BOT_TOKEN) as client:
|
|
19
|
+
await client.send_callback(callback_token, text_response("처리가 완료되었습니다."))
|
|
20
|
+
await client.send_to_group(bot_group_key, text_response("안녕하세요!"))
|
|
21
|
+
await client.send_to_user(bot_user_key, text_response("안녕하세요!")) # channel chat only
|
|
22
|
+
|
|
23
|
+
commands = await client.get_commands()
|
|
24
|
+
await client.update_commands([{"buttonName": "도움말"}, {"buttonName": "상담원 연결", "description": "상담원과 연결합니다."}])
|
|
25
|
+
await client.update_guide("🤖 저와 함께 이런 것들을 할 수 있어요.")
|
|
26
|
+
|
|
27
|
+
response = (
|
|
28
|
+
SkillResponseBuilder()
|
|
29
|
+
.text(f"안녕하세요 {mention_tag('u1')}!")
|
|
30
|
+
.mention("u1", bot_user_key)
|
|
31
|
+
.add(basic_card("제목", "설명", {"imageUrl": "https://example.com/a.png"},
|
|
32
|
+
[web_link_button("열기", "https://example.com"), message_button("다시")]))
|
|
33
|
+
.quick_reply("도움말")
|
|
34
|
+
.build()
|
|
35
|
+
)
|
|
36
|
+
try:
|
|
37
|
+
await client.send_callback(callback_token, response)
|
|
38
|
+
except KakaoBotApiError as e:
|
|
39
|
+
if e.is_callback_token_error: # -881
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
# Blocking variant with the same methods
|
|
43
|
+
with KakaoBotSyncClient(BOT_TOKEN) as client:
|
|
44
|
+
client.send_to_group(bot_group_key, text_response("hi"))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Options: `KakaoBotClient(token, base_url=..., timeout=10.0, headers=..., http=httpx.AsyncClient(...))`.
|
|
48
|
+
|
|
49
|
+
## Gateway (WebSocket)
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
from kakaotalk_bot import KakaoBotGateway, DispatchEvent
|
|
54
|
+
|
|
55
|
+
gateway = KakaoBotGateway(
|
|
56
|
+
BOT_TOKEN,
|
|
57
|
+
# url=..., client=..., auto_reconnect=True, initial_backoff=1.0, max_backoff=30.0,
|
|
58
|
+
# heartbeat_margin=5.0, handshake_timeout=15.0,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@gateway.on("ready")
|
|
62
|
+
def on_ready(ready):
|
|
63
|
+
print("bot", ready["bot"]["id"], "session", ready["session_id"])
|
|
64
|
+
|
|
65
|
+
@gateway.on("MESSAGE_CREATE")
|
|
66
|
+
async def on_message(event: DispatchEvent):
|
|
67
|
+
# event.data: {"id", "content", "timestamp", "isChannelChatroom", "botGroupKey", "botUserKey", "callbackToken"?}
|
|
68
|
+
if event.can_reply:
|
|
69
|
+
await event.reply_text(f"echo: {event.content}")
|
|
70
|
+
|
|
71
|
+
@gateway.on("error")
|
|
72
|
+
def on_error(exc):
|
|
73
|
+
print("gateway error:", exc) # fatal close codes: exc.fatal is True
|
|
74
|
+
|
|
75
|
+
asyncio.run(gateway.run()) # blocks; reconnects automatically
|
|
76
|
+
# or: task = await gateway.start() # background task, returns once READY
|
|
77
|
+
# await gateway.close()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Events: `ready`, `resumed`, `dispatch`, `MESSAGE_CREATE`, `ENTRANCE`, `CHAT_JOIN`, `LEAVE`, `AD_REWARD`,
|
|
81
|
+
`close`, `reconnecting`, `heartbeat_timeout`, `error`. Listeners may be sync or async.
|
|
82
|
+
|
|
83
|
+
Session persistence: save `gateway.session` and call `gateway.restore_session(session_id, last_seq)`
|
|
84
|
+
before `run()` to RESUME across process restarts.
|
|
85
|
+
|
|
86
|
+
## Webhook (FastAPI example)
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from fastapi import FastAPI, Request, Response, BackgroundTasks
|
|
90
|
+
from kakaotalk_bot import KakaoBotClient, parse_webhook_event, verify_custom_header
|
|
91
|
+
|
|
92
|
+
client = KakaoBotClient(BOT_TOKEN)
|
|
93
|
+
app = FastAPI()
|
|
94
|
+
|
|
95
|
+
@app.post("/webhook")
|
|
96
|
+
async def webhook(request: Request, background: BackgroundTasks) -> Response:
|
|
97
|
+
if not verify_custom_header(request.headers.get("X-My-Secret"), WEBHOOK_SECRET):
|
|
98
|
+
return Response(status_code=401)
|
|
99
|
+
event = parse_webhook_event(await request.body(), client)
|
|
100
|
+
if event.type == "MESSAGE_CREATE" and event.can_reply:
|
|
101
|
+
background.add_task(event.reply_text, f"echo: {event.content}")
|
|
102
|
+
return Response(status_code=204) # within 1 second
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pip install -e ".[dev]"
|
|
109
|
+
pytest
|
|
110
|
+
mypy kakaotalk_bot
|
|
111
|
+
```
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""KakaoTalk Bot API client: REST API wrapper, Gateway (WebSocket) client and webhook helpers."""
|
|
2
|
+
from .client import DEFAULT_API_BASE_URL, MAX_COMMANDS, KakaoBotClient, KakaoBotSyncClient
|
|
3
|
+
from .errors import BOT_ERROR_DESCRIPTIONS, KakaoBotApiError, KakaoBotError, KakaoBotGatewayError
|
|
4
|
+
from .events import DispatchEvent, create_dispatch_event, is_dispatch, normalise_frame, parse_frame
|
|
5
|
+
from .gateway import DEFAULT_GATEWAY_URL, LIFECYCLE_EVENTS, KakaoBotGateway, describe_close_code
|
|
6
|
+
from .skill import (
|
|
7
|
+
MAX_MENTIONS,
|
|
8
|
+
MAX_OUTPUTS,
|
|
9
|
+
MAX_QUICK_REPLIES,
|
|
10
|
+
SkillResponseBuilder,
|
|
11
|
+
basic_card,
|
|
12
|
+
button,
|
|
13
|
+
carousel,
|
|
14
|
+
commerce_card,
|
|
15
|
+
guide_button,
|
|
16
|
+
image_response,
|
|
17
|
+
invite_button,
|
|
18
|
+
invite_member_button,
|
|
19
|
+
item_card,
|
|
20
|
+
list_card,
|
|
21
|
+
mention_button,
|
|
22
|
+
mention_tag,
|
|
23
|
+
mentions,
|
|
24
|
+
message_button,
|
|
25
|
+
operator_button,
|
|
26
|
+
phone_button,
|
|
27
|
+
quick_reply,
|
|
28
|
+
settings_button,
|
|
29
|
+
share_button,
|
|
30
|
+
simple_image,
|
|
31
|
+
simple_text,
|
|
32
|
+
skill_response,
|
|
33
|
+
text_card,
|
|
34
|
+
text_response,
|
|
35
|
+
web_link_button,
|
|
36
|
+
)
|
|
37
|
+
from .types import CLOSE_CODE_DESCRIPTIONS, EVENT_TYPES, FATAL_CLOSE_CODES, CloseCode, Opcode
|
|
38
|
+
from .webhook import parse_webhook_event, parse_webhook_payload, verify_custom_header
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"__version__",
|
|
44
|
+
"KakaoBotClient",
|
|
45
|
+
"KakaoBotSyncClient",
|
|
46
|
+
"DEFAULT_API_BASE_URL",
|
|
47
|
+
"MAX_COMMANDS",
|
|
48
|
+
"KakaoBotError",
|
|
49
|
+
"KakaoBotApiError",
|
|
50
|
+
"KakaoBotGatewayError",
|
|
51
|
+
"BOT_ERROR_DESCRIPTIONS",
|
|
52
|
+
"DispatchEvent",
|
|
53
|
+
"create_dispatch_event",
|
|
54
|
+
"parse_frame",
|
|
55
|
+
"normalise_frame",
|
|
56
|
+
"is_dispatch",
|
|
57
|
+
"KakaoBotGateway",
|
|
58
|
+
"DEFAULT_GATEWAY_URL",
|
|
59
|
+
"LIFECYCLE_EVENTS",
|
|
60
|
+
"describe_close_code",
|
|
61
|
+
"SkillResponseBuilder",
|
|
62
|
+
"skill_response",
|
|
63
|
+
"text_response",
|
|
64
|
+
"image_response",
|
|
65
|
+
"simple_text",
|
|
66
|
+
"simple_image",
|
|
67
|
+
"text_card",
|
|
68
|
+
"basic_card",
|
|
69
|
+
"list_card",
|
|
70
|
+
"item_card",
|
|
71
|
+
"commerce_card",
|
|
72
|
+
"carousel",
|
|
73
|
+
"button",
|
|
74
|
+
"message_button",
|
|
75
|
+
"web_link_button",
|
|
76
|
+
"phone_button",
|
|
77
|
+
"share_button",
|
|
78
|
+
"operator_button",
|
|
79
|
+
"invite_button",
|
|
80
|
+
"mention_button",
|
|
81
|
+
"invite_member_button",
|
|
82
|
+
"settings_button",
|
|
83
|
+
"guide_button",
|
|
84
|
+
"quick_reply",
|
|
85
|
+
"mention_tag",
|
|
86
|
+
"mentions",
|
|
87
|
+
"MAX_OUTPUTS",
|
|
88
|
+
"MAX_QUICK_REPLIES",
|
|
89
|
+
"MAX_MENTIONS",
|
|
90
|
+
"Opcode",
|
|
91
|
+
"CloseCode",
|
|
92
|
+
"EVENT_TYPES",
|
|
93
|
+
"FATAL_CLOSE_CODES",
|
|
94
|
+
"CLOSE_CODE_DESCRIPTIONS",
|
|
95
|
+
"parse_webhook_payload",
|
|
96
|
+
"parse_webhook_event",
|
|
97
|
+
"verify_custom_header",
|
|
98
|
+
]
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""REST API client for the KakaoTalk Bot API (async and sync variants)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .errors import KakaoBotApiError
|
|
10
|
+
from .types import CommandsResponse, Guide, SendResult, SkillResponse, UtteranceRequest
|
|
11
|
+
|
|
12
|
+
DEFAULT_API_BASE_URL = "https://kapi.kakao.com"
|
|
13
|
+
MAX_COMMANDS = 30
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _BaseClient:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
bot_token: str,
|
|
20
|
+
*,
|
|
21
|
+
base_url: str = DEFAULT_API_BASE_URL,
|
|
22
|
+
timeout: float = 10.0,
|
|
23
|
+
headers: Mapping[str, str] | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
if not bot_token or not isinstance(bot_token, str):
|
|
26
|
+
raise TypeError("bot_token is required")
|
|
27
|
+
self._token = bot_token
|
|
28
|
+
self.base_url = base_url.rstrip("/")
|
|
29
|
+
self.timeout = timeout
|
|
30
|
+
self._extra_headers = dict(headers or {})
|
|
31
|
+
|
|
32
|
+
# -- request helpers ---------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _headers(self, extra: Mapping[str, str] | None = None, *, has_body: bool) -> dict[str, str]:
|
|
35
|
+
headers = {
|
|
36
|
+
**self._extra_headers,
|
|
37
|
+
"Authorization": f"KakaoAK {self._token}",
|
|
38
|
+
"Accept": "application/json",
|
|
39
|
+
}
|
|
40
|
+
if has_body:
|
|
41
|
+
headers["Content-Type"] = "application/json"
|
|
42
|
+
if extra:
|
|
43
|
+
headers.update(extra)
|
|
44
|
+
return headers
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _encode(json_body: Any | None) -> bytes | None:
|
|
48
|
+
if json_body is None:
|
|
49
|
+
return None
|
|
50
|
+
return json.dumps(json_body, ensure_ascii=False).encode("utf-8")
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _handle(response: httpx.Response) -> Any:
|
|
54
|
+
text = response.text
|
|
55
|
+
body: Any = None
|
|
56
|
+
if text:
|
|
57
|
+
try:
|
|
58
|
+
body = json.loads(text)
|
|
59
|
+
except ValueError:
|
|
60
|
+
body = None
|
|
61
|
+
if response.status_code >= 400:
|
|
62
|
+
raise KakaoBotApiError(response.status_code, body, text)
|
|
63
|
+
return body
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _message_body(
|
|
67
|
+
skill_response: SkillResponse,
|
|
68
|
+
bot_group_key: str | None,
|
|
69
|
+
bot_user_key: str | None,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
if bool(bot_group_key) == bool(bot_user_key):
|
|
72
|
+
raise TypeError("Exactly one of bot_group_key or bot_user_key must be provided")
|
|
73
|
+
body: dict[str, Any] = {}
|
|
74
|
+
if bot_group_key:
|
|
75
|
+
body["botGroupKey"] = bot_group_key
|
|
76
|
+
else:
|
|
77
|
+
body["botUserKey"] = bot_user_key
|
|
78
|
+
body["skillResponse"] = skill_response
|
|
79
|
+
return body
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _validate_commands(utterances: Sequence[UtteranceRequest]) -> list[UtteranceRequest]:
|
|
83
|
+
items = list(utterances)
|
|
84
|
+
if len(items) > MAX_COMMANDS:
|
|
85
|
+
raise ValueError(f"At most {MAX_COMMANDS} commands can be registered")
|
|
86
|
+
return items
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class KakaoBotClient(_BaseClient):
|
|
90
|
+
"""Async REST client.
|
|
91
|
+
|
|
92
|
+
>>> async with KakaoBotClient(BOT_TOKEN) as client:
|
|
93
|
+
... await client.send_callback(callback_token, text_response("안녕하세요!"))
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
bot_token: str,
|
|
99
|
+
*,
|
|
100
|
+
base_url: str = DEFAULT_API_BASE_URL,
|
|
101
|
+
timeout: float = 10.0,
|
|
102
|
+
headers: Mapping[str, str] | None = None,
|
|
103
|
+
http: httpx.AsyncClient | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
super().__init__(bot_token, base_url=base_url, timeout=timeout, headers=headers)
|
|
106
|
+
self._owns_http = http is None
|
|
107
|
+
self._http = http or httpx.AsyncClient(timeout=timeout)
|
|
108
|
+
|
|
109
|
+
async def __aenter__(self) -> KakaoBotClient:
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
113
|
+
await self.aclose()
|
|
114
|
+
|
|
115
|
+
async def aclose(self) -> None:
|
|
116
|
+
if self._owns_http:
|
|
117
|
+
await self._http.aclose()
|
|
118
|
+
|
|
119
|
+
async def request(
|
|
120
|
+
self,
|
|
121
|
+
method: str,
|
|
122
|
+
path: str,
|
|
123
|
+
*,
|
|
124
|
+
json_body: Any | None = None,
|
|
125
|
+
headers: Mapping[str, str] | None = None,
|
|
126
|
+
) -> Any:
|
|
127
|
+
"""Low-level request; exposed for endpoints not yet wrapped."""
|
|
128
|
+
response = await self._http.request(
|
|
129
|
+
method,
|
|
130
|
+
self.base_url + path,
|
|
131
|
+
content=self._encode(json_body),
|
|
132
|
+
headers=self._headers(headers, has_body=json_body is not None),
|
|
133
|
+
timeout=self.timeout,
|
|
134
|
+
)
|
|
135
|
+
return self._handle(response)
|
|
136
|
+
|
|
137
|
+
# -- messages ----------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
async def send_callback(self, callback_token: str, skill_response: SkillResponse) -> SendResult:
|
|
140
|
+
"""POST /v1/bot/callback — reply to an event. Token is single-use and expires in 5 minutes."""
|
|
141
|
+
if not callback_token:
|
|
142
|
+
raise TypeError("callback_token is required")
|
|
143
|
+
result: SendResult = await self.request(
|
|
144
|
+
"POST",
|
|
145
|
+
"/v1/bot/callback",
|
|
146
|
+
json_body={"skillResponse": skill_response},
|
|
147
|
+
headers={"X-Bot-Callback-Token": callback_token},
|
|
148
|
+
)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
async def send_message(
|
|
152
|
+
self,
|
|
153
|
+
skill_response: SkillResponse,
|
|
154
|
+
*,
|
|
155
|
+
bot_group_key: str | None = None,
|
|
156
|
+
bot_user_key: str | None = None,
|
|
157
|
+
) -> SendResult:
|
|
158
|
+
"""POST /v1/bot/send_message — proactively message a room or a channel-chat user."""
|
|
159
|
+
result: SendResult = await self.request(
|
|
160
|
+
"POST",
|
|
161
|
+
"/v1/bot/send_message",
|
|
162
|
+
json_body=self._message_body(skill_response, bot_group_key, bot_user_key),
|
|
163
|
+
)
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
async def send_to_group(self, bot_group_key: str, skill_response: SkillResponse) -> SendResult:
|
|
167
|
+
return await self.send_message(skill_response, bot_group_key=bot_group_key)
|
|
168
|
+
|
|
169
|
+
async def send_to_user(self, bot_user_key: str, skill_response: SkillResponse) -> SendResult:
|
|
170
|
+
return await self.send_message(skill_response, bot_user_key=bot_user_key)
|
|
171
|
+
|
|
172
|
+
# -- commands ----------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
async def get_commands(self) -> CommandsResponse:
|
|
175
|
+
"""GET /v1/bot/commands"""
|
|
176
|
+
result: CommandsResponse = await self.request("GET", "/v1/bot/commands")
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
async def update_commands(self, utterances: Sequence[UtteranceRequest]) -> CommandsResponse:
|
|
180
|
+
"""POST /v1/bot/commands/update — replaces the whole list (max 30)."""
|
|
181
|
+
result: CommandsResponse = await self.request(
|
|
182
|
+
"POST", "/v1/bot/commands/update", json_body={"utterances": self._validate_commands(utterances)}
|
|
183
|
+
)
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
# -- guide -------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
async def get_guide(self) -> Guide:
|
|
189
|
+
"""GET /v1/bot/guide"""
|
|
190
|
+
result: Guide = await self.request("GET", "/v1/bot/guide")
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
async def update_guide(self, contents: str) -> Guide:
|
|
194
|
+
"""POST /v1/bot/guide/update"""
|
|
195
|
+
result: Guide = await self.request("POST", "/v1/bot/guide/update", json_body={"contents": contents})
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class KakaoBotSyncClient(_BaseClient):
|
|
200
|
+
"""Blocking REST client with the same surface as :class:`KakaoBotClient`."""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
bot_token: str,
|
|
205
|
+
*,
|
|
206
|
+
base_url: str = DEFAULT_API_BASE_URL,
|
|
207
|
+
timeout: float = 10.0,
|
|
208
|
+
headers: Mapping[str, str] | None = None,
|
|
209
|
+
http: httpx.Client | None = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
super().__init__(bot_token, base_url=base_url, timeout=timeout, headers=headers)
|
|
212
|
+
self._owns_http = http is None
|
|
213
|
+
self._http = http or httpx.Client(timeout=timeout)
|
|
214
|
+
|
|
215
|
+
def __enter__(self) -> KakaoBotSyncClient:
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
def __exit__(self, *exc: object) -> None:
|
|
219
|
+
self.close()
|
|
220
|
+
|
|
221
|
+
def close(self) -> None:
|
|
222
|
+
if self._owns_http:
|
|
223
|
+
self._http.close()
|
|
224
|
+
|
|
225
|
+
def request(
|
|
226
|
+
self,
|
|
227
|
+
method: str,
|
|
228
|
+
path: str,
|
|
229
|
+
*,
|
|
230
|
+
json_body: Any | None = None,
|
|
231
|
+
headers: Mapping[str, str] | None = None,
|
|
232
|
+
) -> Any:
|
|
233
|
+
response = self._http.request(
|
|
234
|
+
method,
|
|
235
|
+
self.base_url + path,
|
|
236
|
+
content=self._encode(json_body),
|
|
237
|
+
headers=self._headers(headers, has_body=json_body is not None),
|
|
238
|
+
timeout=self.timeout,
|
|
239
|
+
)
|
|
240
|
+
return self._handle(response)
|
|
241
|
+
|
|
242
|
+
def send_callback(self, callback_token: str, skill_response: SkillResponse) -> SendResult:
|
|
243
|
+
if not callback_token:
|
|
244
|
+
raise TypeError("callback_token is required")
|
|
245
|
+
result: SendResult = self.request(
|
|
246
|
+
"POST",
|
|
247
|
+
"/v1/bot/callback",
|
|
248
|
+
json_body={"skillResponse": skill_response},
|
|
249
|
+
headers={"X-Bot-Callback-Token": callback_token},
|
|
250
|
+
)
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
def send_message(
|
|
254
|
+
self,
|
|
255
|
+
skill_response: SkillResponse,
|
|
256
|
+
*,
|
|
257
|
+
bot_group_key: str | None = None,
|
|
258
|
+
bot_user_key: str | None = None,
|
|
259
|
+
) -> SendResult:
|
|
260
|
+
result: SendResult = self.request(
|
|
261
|
+
"POST",
|
|
262
|
+
"/v1/bot/send_message",
|
|
263
|
+
json_body=self._message_body(skill_response, bot_group_key, bot_user_key),
|
|
264
|
+
)
|
|
265
|
+
return result
|
|
266
|
+
|
|
267
|
+
def send_to_group(self, bot_group_key: str, skill_response: SkillResponse) -> SendResult:
|
|
268
|
+
return self.send_message(skill_response, bot_group_key=bot_group_key)
|
|
269
|
+
|
|
270
|
+
def send_to_user(self, bot_user_key: str, skill_response: SkillResponse) -> SendResult:
|
|
271
|
+
return self.send_message(skill_response, bot_user_key=bot_user_key)
|
|
272
|
+
|
|
273
|
+
def get_commands(self) -> CommandsResponse:
|
|
274
|
+
result: CommandsResponse = self.request("GET", "/v1/bot/commands")
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
def update_commands(self, utterances: Sequence[UtteranceRequest]) -> CommandsResponse:
|
|
278
|
+
result: CommandsResponse = self.request(
|
|
279
|
+
"POST", "/v1/bot/commands/update", json_body={"utterances": self._validate_commands(utterances)}
|
|
280
|
+
)
|
|
281
|
+
return result
|
|
282
|
+
|
|
283
|
+
def get_guide(self) -> Guide:
|
|
284
|
+
result: Guide = self.request("GET", "/v1/bot/guide")
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
def update_guide(self, contents: str) -> Guide:
|
|
288
|
+
result: Guide = self.request("POST", "/v1/bot/guide/update", json_body={"contents": contents})
|
|
289
|
+
return result
|