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.
@@ -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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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
+ ]