pykabot 0.4.0__py3-none-any.whl
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.
- kabot/THIRD_PARTY_NOTICES.md +130 -0
- kabot/__init__.py +37 -0
- kabot/__main__.py +3 -0
- kabot/bot.py +132 -0
- kabot/cli.py +310 -0
- kabot/loco/__init__.py +57 -0
- kabot/loco/adapter.py +62 -0
- kabot/loco/auth.py +367 -0
- kabot/loco/chats.py +135 -0
- kabot/loco/client.py +437 -0
- kabot/loco/crypto.py +132 -0
- kabot/loco/gui.py +258 -0
- kabot/loco/interactive.py +255 -0
- kabot/loco/live_test.py +270 -0
- kabot/loco/open_profile.py +111 -0
- kabot/loco/packet.py +197 -0
- kabot/loco/session.py +156 -0
- kabot/loco/storage.py +229 -0
- kabot/loco/transport.py +139 -0
- kabot/models.py +125 -0
- kabot/py.typed +0 -0
- kabot/rest.py +412 -0
- kabot/server.py +127 -0
- kabot/state.py +75 -0
- pykabot-0.4.0.dist-info/METADATA +148 -0
- pykabot-0.4.0.dist-info/RECORD +30 -0
- pykabot-0.4.0.dist-info/WHEEL +4 -0
- pykabot-0.4.0.dist-info/entry_points.txt +2 -0
- pykabot-0.4.0.dist-info/licenses/LICENSE +21 -0
- pykabot-0.4.0.dist-info/licenses/docs/THIRD_PARTY_NOTICES.md +130 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
The read-only chat pagination/title fields and TLS service-identity observations
|
|
4
|
+
also reference [agent-messenger](https://github.com/agent-messenger/agent-messenger),
|
|
5
|
+
commits `e58739d80305d5e8ddf72e9aad085c2069f4fd3c` and
|
|
6
|
+
`f4990c8d6172d0b8c4e1523262ee94349c120ff7`. The Python algorithms were written
|
|
7
|
+
independently with bounded cursors and explicit identity checks. For attribution:
|
|
8
|
+
|
|
9
|
+
MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Jeon Suyeol
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
|
|
31
|
+
## Initial LOCO implementation references
|
|
32
|
+
|
|
33
|
+
The experimental LOCO framing, field identifiers, reference RSA public key and
|
|
34
|
+
cryptographic handshake format, desktop-profile session requests and message
|
|
35
|
+
envelopes in `src/kabot/loco/`
|
|
36
|
+
were implemented with reference to the MIT-licensed project below. The Python
|
|
37
|
+
implementation adds strict method validation, configurable bounded parsing,
|
|
38
|
+
incremental decoding and authenticated-record checks.
|
|
39
|
+
|
|
40
|
+
- Project: [openkakao-cli](https://github.com/JungHoonGhae/openkakao-cli)
|
|
41
|
+
- Pinned source commit: `6ca6ea2fd05a6660c4a80c0b61b43b416653d1d5`
|
|
42
|
+
- Sources: `src/loco/packet.rs`, `src/loco/crypto.rs`, `src/loco/client.rs`,
|
|
43
|
+
`src/loco/message.rs`, `src/commands/send.rs`
|
|
44
|
+
- Reference format and public key are historical source observations; they
|
|
45
|
+
do not establish current live server compatibility or successful login.
|
|
46
|
+
|
|
47
|
+
## openkakao-cli license
|
|
48
|
+
|
|
49
|
+
MIT License
|
|
50
|
+
|
|
51
|
+
Copyright (c) 2026 Lucas (JungHoonGhae)
|
|
52
|
+
|
|
53
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
54
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
55
|
+
in the Software without restriction, including without limitation the rights
|
|
56
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
57
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
58
|
+
furnished to do so, subject to the following conditions:
|
|
59
|
+
|
|
60
|
+
The above copyright notice and this permission notice shall be included in all
|
|
61
|
+
copies or substantial portions of the Software.
|
|
62
|
+
|
|
63
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
64
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
65
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
66
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
67
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
68
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
69
|
+
SOFTWARE.
|
|
70
|
+
|
|
71
|
+
## node-kakao open-chat profile protocol reference
|
|
72
|
+
|
|
73
|
+
Open-chat profile serialization and switching use schemas described by
|
|
74
|
+
[node-kakao](https://github.com/storycraft/node-kakao), pinned commit
|
|
75
|
+
`8512e4699e2cd4c26516b80d38b57b2f85a6cc8a`. Relevant sources are
|
|
76
|
+
`open-link-profile.ts`, `talk-open-channel-session.ts`, and
|
|
77
|
+
`talk-open-link-session.ts`. Reference source scripts were not executed.
|
|
78
|
+
|
|
79
|
+
MIT License
|
|
80
|
+
|
|
81
|
+
Copyright (c) 2021 storycraft
|
|
82
|
+
|
|
83
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
84
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
85
|
+
in the Software without restriction, including without limitation the rights
|
|
86
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
87
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
88
|
+
furnished to do so, subject to the following conditions:
|
|
89
|
+
|
|
90
|
+
The above copyright notice and this permission notice shall be included in all
|
|
91
|
+
copies or substantial portions of the Software.
|
|
92
|
+
|
|
93
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
94
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
95
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
96
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
97
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
98
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
99
|
+
SOFTWARE.
|
|
100
|
+
|
|
101
|
+
## loco-wrapper protocol reference
|
|
102
|
+
|
|
103
|
+
The independent Android authentication request builders and Android LOCO profile
|
|
104
|
+
use protocol facts described in
|
|
105
|
+
[loco-wrapper](https://github.com/NetRiceCake/loco-wrapper), pinned commit
|
|
106
|
+
`1dec3b79e59f2776ebd1f03e60744162ff195b24` (2026-10-03), particularly
|
|
107
|
+
`KakaoApi.java` and outbound login request models. The following notice is
|
|
108
|
+
included for reference and attribution. Source scripts were not executed.
|
|
109
|
+
|
|
110
|
+
MIT License
|
|
111
|
+
|
|
112
|
+
Copyright (c) 2026 NetRiceCake
|
|
113
|
+
|
|
114
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
115
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
116
|
+
in the Software without restriction, including without limitation the rights
|
|
117
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
118
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
119
|
+
furnished to do so, subject to the following conditions:
|
|
120
|
+
|
|
121
|
+
The above copyright notice and this permission notice shall be included in all
|
|
122
|
+
copies or substantial portions of the Software.
|
|
123
|
+
|
|
124
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
125
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
126
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
127
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
128
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
129
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
130
|
+
SOFTWARE.
|
kabot/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Kabot: client-free Kakao channel bots and official personal message APIs."""
|
|
2
|
+
|
|
3
|
+
from .bot import Bot, Context
|
|
4
|
+
from .models import (
|
|
5
|
+
Response,
|
|
6
|
+
SkillRequest,
|
|
7
|
+
card,
|
|
8
|
+
image,
|
|
9
|
+
link_button,
|
|
10
|
+
message_button,
|
|
11
|
+
quick_reply,
|
|
12
|
+
text,
|
|
13
|
+
)
|
|
14
|
+
from .rest import KakaoAPIError, KakaoClient, OAuthTokens, authorization_url, validate_state
|
|
15
|
+
from .server import create_app
|
|
16
|
+
from .state import SQLiteStore
|
|
17
|
+
|
|
18
|
+
__version__ = "0.4.0"
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Bot",
|
|
21
|
+
"Context",
|
|
22
|
+
"Response",
|
|
23
|
+
"SkillRequest",
|
|
24
|
+
"SQLiteStore",
|
|
25
|
+
"create_app",
|
|
26
|
+
"text",
|
|
27
|
+
"image",
|
|
28
|
+
"card",
|
|
29
|
+
"message_button",
|
|
30
|
+
"link_button",
|
|
31
|
+
"quick_reply",
|
|
32
|
+
"KakaoAPIError",
|
|
33
|
+
"KakaoClient",
|
|
34
|
+
"OAuthTokens",
|
|
35
|
+
"authorization_url",
|
|
36
|
+
"validate_state",
|
|
37
|
+
]
|
kabot/__main__.py
ADDED
kabot/bot.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Transport-independent command routing for Kakao channel skills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Callable, Pattern
|
|
10
|
+
|
|
11
|
+
from .models import Response, SkillRequest, text
|
|
12
|
+
from .state import SQLiteStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Context:
|
|
17
|
+
request: SkillRequest
|
|
18
|
+
store: SQLiteStore | None = None
|
|
19
|
+
args: str = ""
|
|
20
|
+
match: re.Match | None = None
|
|
21
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def user_id(self) -> str:
|
|
25
|
+
return self.request.user_request.user.id
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def scope(self) -> str:
|
|
29
|
+
# Bot user keys are scoped to each bot, not global Kakao account IDs.
|
|
30
|
+
return f"{self.request.bot.get('id', 'local')}:{self.user_id}"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def message(self) -> str:
|
|
34
|
+
return self.request.user_request.utterance
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def params(self) -> dict[str, Any]:
|
|
38
|
+
return self.request.action.params
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Route:
|
|
43
|
+
kind: str
|
|
44
|
+
value: str | Pattern
|
|
45
|
+
handler: Callable
|
|
46
|
+
deferred: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def invoke(function: Callable, *args: Any) -> Any:
|
|
50
|
+
if inspect.iscoroutinefunction(function):
|
|
51
|
+
return await function(*args)
|
|
52
|
+
result = await asyncio.to_thread(function, *args)
|
|
53
|
+
return await result if inspect.isawaitable(result) else result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Bot:
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
prefix: str = "/",
|
|
61
|
+
store: SQLiteStore | None = None,
|
|
62
|
+
fallback: str = "알 수 없는 명령입니다. /도움말을 입력해 주세요.",
|
|
63
|
+
):
|
|
64
|
+
self.prefix, self.store, self.fallback = prefix, store, fallback
|
|
65
|
+
self.routes: list[Route] = []
|
|
66
|
+
self.before_hooks: list[Callable] = []
|
|
67
|
+
|
|
68
|
+
def _register(self, kind: str, value: str | Pattern, *, deferred: bool = False):
|
|
69
|
+
def decorator(function: Callable):
|
|
70
|
+
self.routes.append(Route(kind, value, function, deferred))
|
|
71
|
+
return function
|
|
72
|
+
|
|
73
|
+
return decorator
|
|
74
|
+
|
|
75
|
+
def command(self, name: str, *, deferred: bool = False):
|
|
76
|
+
if not name or any(c.isspace() for c in name):
|
|
77
|
+
raise ValueError("command name must be a non-empty single word")
|
|
78
|
+
return self._register("command", name, deferred=deferred)
|
|
79
|
+
|
|
80
|
+
def hears(self, pattern: str, *, deferred: bool = False):
|
|
81
|
+
return self._register("regex", re.compile(pattern), deferred=deferred)
|
|
82
|
+
|
|
83
|
+
def action(self, name: str, *, deferred: bool = False):
|
|
84
|
+
return self._register("action", name, deferred=deferred)
|
|
85
|
+
|
|
86
|
+
def on_message(self, *, deferred: bool = False):
|
|
87
|
+
return self._register("all", "", deferred=deferred)
|
|
88
|
+
|
|
89
|
+
def before(self, function: Callable):
|
|
90
|
+
"""Return a response to stop routing, or None to continue."""
|
|
91
|
+
self.before_hooks.append(function)
|
|
92
|
+
return function
|
|
93
|
+
|
|
94
|
+
def resolve(self, request: SkillRequest) -> tuple[Route | None, Context]:
|
|
95
|
+
ctx = Context(request, self.store)
|
|
96
|
+
message = ctx.message.strip()
|
|
97
|
+
for route in self.routes:
|
|
98
|
+
if route.kind == "command":
|
|
99
|
+
parts = message.split(maxsplit=1)
|
|
100
|
+
if not parts or parts[0] != self.prefix + route.value:
|
|
101
|
+
continue
|
|
102
|
+
ctx.args = parts[1] if len(parts) > 1 else ""
|
|
103
|
+
elif route.kind == "regex":
|
|
104
|
+
ctx.match = route.value.search(message)
|
|
105
|
+
if ctx.match is None:
|
|
106
|
+
continue
|
|
107
|
+
elif route.kind == "action" and request.action.name != route.value:
|
|
108
|
+
continue
|
|
109
|
+
return route, ctx
|
|
110
|
+
return None, ctx
|
|
111
|
+
|
|
112
|
+
async def execute(self, route: Route | None, ctx: Context) -> Response:
|
|
113
|
+
for hook in self.before_hooks:
|
|
114
|
+
result = await invoke(hook, ctx)
|
|
115
|
+
if result is not None:
|
|
116
|
+
return self._response(result)
|
|
117
|
+
return self._response(await invoke(route.handler, ctx) if route else self.fallback)
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _response(value: Response | str) -> Response:
|
|
121
|
+
result = text(value) if isinstance(value, str) else value
|
|
122
|
+
if not isinstance(result, Response):
|
|
123
|
+
raise TypeError("A bot handler must return a string or kabot.Response")
|
|
124
|
+
result.to_dict()
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
async def handle(self, payload: dict | SkillRequest) -> Response:
|
|
128
|
+
"""Local/test dispatch. Does not send messages or enforce HTTP deadlines."""
|
|
129
|
+
request = (
|
|
130
|
+
payload if isinstance(payload, SkillRequest) else SkillRequest.model_validate(payload)
|
|
131
|
+
)
|
|
132
|
+
return await self.execute(*self.resolve(request))
|
kabot/cli.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Local scaffold, simulator and server runner. Never reads Kakao desktop data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import importlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .bot import Bot
|
|
15
|
+
from .server import create_app
|
|
16
|
+
|
|
17
|
+
SCAFFOLD = """from kabot import Bot, SQLiteStore, quick_reply, text
|
|
18
|
+
|
|
19
|
+
bot = Bot(store=SQLiteStore("bot.sqlite3"))
|
|
20
|
+
|
|
21
|
+
@bot.command("도움말")
|
|
22
|
+
def help(ctx):
|
|
23
|
+
return text("/안녕 이름, /방문, /메모 내용, /메모확인").with_quick_replies(
|
|
24
|
+
quick_reply("인사", "/안녕 친구"), quick_reply("방문 횟수", "/방문"))
|
|
25
|
+
|
|
26
|
+
@bot.command("안녕")
|
|
27
|
+
def hello(ctx):
|
|
28
|
+
return f"안녕하세요, {ctx.args or '친구'}님!"
|
|
29
|
+
|
|
30
|
+
@bot.command("방문")
|
|
31
|
+
def visit(ctx):
|
|
32
|
+
count = ctx.store.increment(ctx.scope, "visits")
|
|
33
|
+
return f"{count}번째 방문입니다."
|
|
34
|
+
|
|
35
|
+
@bot.command("메모")
|
|
36
|
+
def memo(ctx):
|
|
37
|
+
if not ctx.args:
|
|
38
|
+
return "사용법: /메모 기억할 내용"
|
|
39
|
+
ctx.store.set(ctx.scope, "memo", ctx.args, ttl=86400)
|
|
40
|
+
return "하루 동안 기억할게요."
|
|
41
|
+
|
|
42
|
+
@bot.command("메모확인")
|
|
43
|
+
def read_memo(ctx):
|
|
44
|
+
return ctx.store.get(ctx.scope, "memo", "저장한 메모가 없어요.")
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
PERSONAL_SCAFFOLD = """from kabot import Bot, SQLiteStore
|
|
48
|
+
|
|
49
|
+
bot = Bot(store=SQLiteStore("personal-bot.sqlite3"))
|
|
50
|
+
|
|
51
|
+
@bot.command("도움말")
|
|
52
|
+
def help_command(ctx):
|
|
53
|
+
return "/핑, /안녕 이름, /방문 명령을 사용할 수 있습니다."
|
|
54
|
+
|
|
55
|
+
@bot.command("핑")
|
|
56
|
+
async def ping(ctx):
|
|
57
|
+
return "퐁!"
|
|
58
|
+
|
|
59
|
+
@bot.command("안녕")
|
|
60
|
+
def hello(ctx):
|
|
61
|
+
return f"안녕하세요, {ctx.args or '친구'}님!"
|
|
62
|
+
|
|
63
|
+
@bot.command("방문")
|
|
64
|
+
def visit(ctx):
|
|
65
|
+
return f"{ctx.store.increment(ctx.scope, 'visits')}번째 방문입니다."
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_bot(reference: str) -> Bot:
|
|
70
|
+
module_name, separator, name = reference.partition(":")
|
|
71
|
+
if not separator or not module_name or not name:
|
|
72
|
+
raise ValueError("Use module:bot, for example mybot:bot")
|
|
73
|
+
# Import operator-provided code from the working directory, as with uvicorn.
|
|
74
|
+
sys.path.insert(0, str(Path.cwd()))
|
|
75
|
+
bot = getattr(importlib.import_module(module_name), name)
|
|
76
|
+
if not isinstance(bot, Bot):
|
|
77
|
+
raise TypeError("The referenced object must be kabot.Bot")
|
|
78
|
+
return bot
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def simulate(bot: Bot) -> None:
|
|
82
|
+
print("로컬 시뮬레이터입니다. 실제 카카오톡에는 전송하지 않습니다. /종료로 끝냅니다.")
|
|
83
|
+
while True:
|
|
84
|
+
try:
|
|
85
|
+
message = await asyncio.to_thread(input, "> ")
|
|
86
|
+
except EOFError:
|
|
87
|
+
return
|
|
88
|
+
if message == "/종료":
|
|
89
|
+
return
|
|
90
|
+
payload = {
|
|
91
|
+
"bot": {"id": "local"},
|
|
92
|
+
"userRequest": {"utterance": message, "user": {"id": "local-user"}},
|
|
93
|
+
}
|
|
94
|
+
response = await bot.handle(payload)
|
|
95
|
+
print(json.dumps(response.to_dict(), ensure_ascii=False, indent=2))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main() -> None:
|
|
99
|
+
parser = argparse.ArgumentParser(prog="kabot", description="Kakao channel skill bot tools")
|
|
100
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
101
|
+
init = sub.add_parser("init", help="Create an example bot without overwriting files")
|
|
102
|
+
init.add_argument("file", nargs="?", default="mybot.py")
|
|
103
|
+
init.add_argument(
|
|
104
|
+
"--personal", action="store_true", help="Create a text-only personal/group bot"
|
|
105
|
+
)
|
|
106
|
+
local = sub.add_parser("chat", help="Run a local simulator (no Kakao network traffic)")
|
|
107
|
+
local.add_argument("bot", default="mybot:bot", nargs="?")
|
|
108
|
+
run = sub.add_parser("run", help="Start a channel skill server")
|
|
109
|
+
run.add_argument("bot", default="mybot:bot", nargs="?")
|
|
110
|
+
run.add_argument("--host", default="127.0.0.1")
|
|
111
|
+
run.add_argument("--port", type=int, default=8000)
|
|
112
|
+
login = sub.add_parser(
|
|
113
|
+
"login", help="Interactive experimental Android login; never pass passwords as arguments"
|
|
114
|
+
)
|
|
115
|
+
login.add_argument("--session", default="personal-session.kabot")
|
|
116
|
+
login.add_argument(
|
|
117
|
+
"--status-file", help="Write redacted phase/status codes for local progress monitoring"
|
|
118
|
+
)
|
|
119
|
+
login.add_argument("--skip-loco-check", action="store_true")
|
|
120
|
+
login.add_argument(
|
|
121
|
+
"--replace",
|
|
122
|
+
action="store_true",
|
|
123
|
+
help="Unlock and replace an existing session, reusing its device ID",
|
|
124
|
+
)
|
|
125
|
+
chats = sub.add_parser(
|
|
126
|
+
"chats", help="Unlock an encrypted personal session and list chat IDs locally"
|
|
127
|
+
)
|
|
128
|
+
chats.add_argument("--session", default="personal-session.kabot")
|
|
129
|
+
profile = sub.add_parser(
|
|
130
|
+
"open-profile", help="List open links, read a room profile, or explicitly switch it"
|
|
131
|
+
)
|
|
132
|
+
profile.add_argument("--session", default="personal-session.kabot")
|
|
133
|
+
profile.add_argument("--chat-id", type=int)
|
|
134
|
+
profile.add_argument("--kind", choices=("main", "anonymous", "link"))
|
|
135
|
+
profile.add_argument("--nickname")
|
|
136
|
+
profile.add_argument(
|
|
137
|
+
"--profile-path", help="Existing Kakao image path, not a local file or URL"
|
|
138
|
+
)
|
|
139
|
+
profile.add_argument("--profile-link-id", type=int)
|
|
140
|
+
verify = sub.add_parser(
|
|
141
|
+
"verify", help="Unlock a saved session and check chat login without sending messages"
|
|
142
|
+
)
|
|
143
|
+
verify.add_argument("--session", default="personal-session.kabot")
|
|
144
|
+
verify.add_argument("--status-file")
|
|
145
|
+
bot_test = sub.add_parser(
|
|
146
|
+
"bot-test", help="Locally select a test room and authorize one ping reply"
|
|
147
|
+
)
|
|
148
|
+
bot_test.add_argument("--session", default="personal-session.kabot")
|
|
149
|
+
bot_test.add_argument("--status-file")
|
|
150
|
+
bot_test.add_argument("--target-room")
|
|
151
|
+
bot_test.add_argument("--allow-open", action="store_true")
|
|
152
|
+
gui = sub.add_parser(
|
|
153
|
+
"gui", help="Open a local user-operated session verification or login window"
|
|
154
|
+
)
|
|
155
|
+
gui.add_argument("--session", default="personal-session.kabot")
|
|
156
|
+
gui.add_argument("--status-file")
|
|
157
|
+
gui.add_argument("--mode", choices=("verify", "login", "bot-test"), default="verify")
|
|
158
|
+
gui.add_argument("--replace", action="store_true")
|
|
159
|
+
gui.add_argument("--target-room")
|
|
160
|
+
gui.add_argument("--allow-open", action="store_true")
|
|
161
|
+
personal = sub.add_parser("personal", help="Run an experimental text bot in selected chats")
|
|
162
|
+
personal.add_argument("bot", default="mybot:bot", nargs="?")
|
|
163
|
+
personal.add_argument("--session", default="personal-session.kabot")
|
|
164
|
+
personal.add_argument("--chat-id", type=int, action="append", required=True)
|
|
165
|
+
sub.add_parser("secret", help="Generate a secret for KABOT_WEBHOOK_SECRET")
|
|
166
|
+
sub.add_parser("doctor", help="Show supported transports and configuration status")
|
|
167
|
+
args = parser.parse_args()
|
|
168
|
+
try:
|
|
169
|
+
if args.command == "init":
|
|
170
|
+
with Path(args.file).open("x", encoding="utf-8") as output:
|
|
171
|
+
output.write(PERSONAL_SCAFFOLD if args.personal else SCAFFOLD)
|
|
172
|
+
created = Path(args.file)
|
|
173
|
+
print(
|
|
174
|
+
f"Created {created}. From {created.resolve().parent}, run: "
|
|
175
|
+
f"kabot chat {created.stem}:bot"
|
|
176
|
+
)
|
|
177
|
+
elif args.command == "secret":
|
|
178
|
+
print(secrets.token_urlsafe(32))
|
|
179
|
+
elif args.command == "doctor":
|
|
180
|
+
print(
|
|
181
|
+
"Channel skill server: supported (public HTTPS + Kakao channel configuration required)"
|
|
182
|
+
)
|
|
183
|
+
print(
|
|
184
|
+
"Personal official API: self/friend outbound messages; OAuth and permissions required"
|
|
185
|
+
)
|
|
186
|
+
print(
|
|
187
|
+
"Personal/group LOCO: experimental transport/session; live compatibility unverified"
|
|
188
|
+
)
|
|
189
|
+
print(
|
|
190
|
+
"Android26.8.2 password/device registration: experimental; account verification required"
|
|
191
|
+
)
|
|
192
|
+
print("Automatic token renewal and QR login: not implemented")
|
|
193
|
+
print("Webhook secret configured: " + str(bool(os.getenv("KABOT_WEBHOOK_SECRET"))))
|
|
194
|
+
elif args.command == "chat":
|
|
195
|
+
asyncio.run(simulate(load_bot(args.bot)))
|
|
196
|
+
elif args.command == "gui":
|
|
197
|
+
from .loco.gui import run_window
|
|
198
|
+
|
|
199
|
+
run_window(
|
|
200
|
+
args.session,
|
|
201
|
+
status_path=args.status_file,
|
|
202
|
+
mode=args.mode,
|
|
203
|
+
replace=args.replace,
|
|
204
|
+
target_name=args.target_room,
|
|
205
|
+
allow_open=args.allow_open,
|
|
206
|
+
)
|
|
207
|
+
elif args.command in ("login", "chats", "personal", "verify", "bot-test", "open-profile"):
|
|
208
|
+
from .loco import LocoConnectionError, LocoStatusError, PersonalClient, run_personal_bot
|
|
209
|
+
from .loco.auth import AndroidAuthError
|
|
210
|
+
from .loco.interactive import (
|
|
211
|
+
interactive_login,
|
|
212
|
+
manage_open_profile,
|
|
213
|
+
show_chats,
|
|
214
|
+
unlock_session,
|
|
215
|
+
verify_saved_session,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
if args.command == "login":
|
|
220
|
+
asyncio.run(
|
|
221
|
+
interactive_login(
|
|
222
|
+
args.session,
|
|
223
|
+
status_path=args.status_file,
|
|
224
|
+
verify_loco=not args.skip_loco_check,
|
|
225
|
+
replace=args.replace,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
elif args.command == "chats":
|
|
229
|
+
asyncio.run(show_chats(args.session))
|
|
230
|
+
elif args.command == "open-profile":
|
|
231
|
+
from .loco import OpenChatProfile
|
|
232
|
+
from .loco.client import positive_id
|
|
233
|
+
|
|
234
|
+
if args.chat_id is not None:
|
|
235
|
+
positive_id(args.chat_id, "chat_id")
|
|
236
|
+
if args.kind is not None and args.chat_id is None:
|
|
237
|
+
raise ValueError("--chat-id is required to change a room profile")
|
|
238
|
+
if args.kind is None and any(
|
|
239
|
+
v is not None
|
|
240
|
+
for v in (args.nickname, args.profile_path, args.profile_link_id)
|
|
241
|
+
):
|
|
242
|
+
raise ValueError("--kind is required with profile options")
|
|
243
|
+
selection = (
|
|
244
|
+
OpenChatProfile(
|
|
245
|
+
args.kind,
|
|
246
|
+
nickname=args.nickname,
|
|
247
|
+
profile_path=args.profile_path,
|
|
248
|
+
profile_link_id=args.profile_link_id,
|
|
249
|
+
)
|
|
250
|
+
if args.kind is not None
|
|
251
|
+
else None
|
|
252
|
+
)
|
|
253
|
+
asyncio.run(
|
|
254
|
+
manage_open_profile(args.session, chat_id=args.chat_id, profile=selection)
|
|
255
|
+
)
|
|
256
|
+
elif args.command == "verify":
|
|
257
|
+
asyncio.run(verify_saved_session(args.session, status_path=args.status_file))
|
|
258
|
+
elif args.command == "bot-test":
|
|
259
|
+
from .loco.live_test import run_reply_test
|
|
260
|
+
|
|
261
|
+
asyncio.run(
|
|
262
|
+
run_reply_test(
|
|
263
|
+
args.session,
|
|
264
|
+
status_path=args.status_file,
|
|
265
|
+
target_name=args.target_room,
|
|
266
|
+
allow_open=args.allow_open,
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
bot = load_bot(args.bot)
|
|
271
|
+
|
|
272
|
+
async def personal_main():
|
|
273
|
+
credentials = await unlock_session(args.session)
|
|
274
|
+
async with PersonalClient(credentials) as client:
|
|
275
|
+
await client.connect()
|
|
276
|
+
await run_personal_bot(bot, client, chat_ids=args.chat_id)
|
|
277
|
+
|
|
278
|
+
asyncio.run(personal_main())
|
|
279
|
+
except (
|
|
280
|
+
AndroidAuthError,
|
|
281
|
+
LocoConnectionError,
|
|
282
|
+
LocoStatusError,
|
|
283
|
+
OSError,
|
|
284
|
+
TimeoutError,
|
|
285
|
+
) as exc:
|
|
286
|
+
parser.exit(1, f"kabot: {exc}\n")
|
|
287
|
+
except KeyboardInterrupt:
|
|
288
|
+
parser.exit(130, "\nStopped.\n")
|
|
289
|
+
elif args.command == "run":
|
|
290
|
+
secret = os.getenv("KABOT_WEBHOOK_SECRET", "")
|
|
291
|
+
if not secret:
|
|
292
|
+
parser.error("Set KABOT_WEBHOOK_SECRET first; generate one with kabot secret")
|
|
293
|
+
app = create_app(
|
|
294
|
+
load_bot(args.bot),
|
|
295
|
+
webhook_secret=secret,
|
|
296
|
+
expected_bot_id=os.getenv("KABOT_BOT_ID") or None,
|
|
297
|
+
callback_hosts=tuple(
|
|
298
|
+
h.strip() for h in os.getenv("KABOT_CALLBACK_HOSTS", "").split(",") if h.strip()
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
import uvicorn
|
|
302
|
+
|
|
303
|
+
# URLs contain a webhook secret: do not print access logs.
|
|
304
|
+
uvicorn.run(app, host=args.host, port=args.port, access_log=False)
|
|
305
|
+
except (ValueError, TypeError, FileExistsError, ImportError, AttributeError) as exc:
|
|
306
|
+
parser.exit(1, f"kabot: {exc}\n")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
main()
|
kabot/loco/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Experimental, locally tested LOCO components. Live Kakao compatibility is unverified.
|
|
2
|
+
|
|
3
|
+
Install with pip install 'kabot-python[loco]'. No account login credentials are discovered.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from importlib.util import find_spec
|
|
7
|
+
|
|
8
|
+
if find_spec("bson") is None or find_spec("cryptography") is None:
|
|
9
|
+
raise ImportError("Install the optional LOCO dependencies: pip install 'kabot-python[loco]'")
|
|
10
|
+
|
|
11
|
+
from .adapter import run_personal_bot
|
|
12
|
+
from .auth import (
|
|
13
|
+
ANDROID_26_8_2,
|
|
14
|
+
AndroidAuthClient,
|
|
15
|
+
AndroidAuthError,
|
|
16
|
+
AndroidAuthProfile,
|
|
17
|
+
AndroidTokens,
|
|
18
|
+
RegistrationChallenge,
|
|
19
|
+
RegistrationRequired,
|
|
20
|
+
RegistrationStatus,
|
|
21
|
+
generate_device_uuid,
|
|
22
|
+
)
|
|
23
|
+
from .client import Credentials, Message, PersonalClient, received_messages
|
|
24
|
+
from .crypto import CryptoError, LocoEncryptor
|
|
25
|
+
from .open_profile import OpenChatProfile
|
|
26
|
+
from .packet import LocoPacket, PacketDecoder, PacketError
|
|
27
|
+
from .session import EventOverflowError, LocoSession, LocoStatusError
|
|
28
|
+
from .transport import Endpoint, LocoConnectionError, LocoTransport
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ANDROID_26_8_2",
|
|
32
|
+
"AndroidAuthClient",
|
|
33
|
+
"AndroidAuthError",
|
|
34
|
+
"AndroidAuthProfile",
|
|
35
|
+
"AndroidTokens",
|
|
36
|
+
"RegistrationChallenge",
|
|
37
|
+
"RegistrationRequired",
|
|
38
|
+
"RegistrationStatus",
|
|
39
|
+
"generate_device_uuid",
|
|
40
|
+
"Credentials",
|
|
41
|
+
"CryptoError",
|
|
42
|
+
"Endpoint",
|
|
43
|
+
"EventOverflowError",
|
|
44
|
+
"LocoConnectionError",
|
|
45
|
+
"LocoEncryptor",
|
|
46
|
+
"LocoPacket",
|
|
47
|
+
"LocoSession",
|
|
48
|
+
"LocoStatusError",
|
|
49
|
+
"LocoTransport",
|
|
50
|
+
"Message",
|
|
51
|
+
"OpenChatProfile",
|
|
52
|
+
"PacketDecoder",
|
|
53
|
+
"PacketError",
|
|
54
|
+
"PersonalClient",
|
|
55
|
+
"received_messages",
|
|
56
|
+
"run_personal_bot",
|
|
57
|
+
]
|