kyber-chat 1.0.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.
Files changed (71) hide show
  1. kyber/__init__.py +6 -0
  2. kyber/__main__.py +8 -0
  3. kyber/agent/__init__.py +8 -0
  4. kyber/agent/context.py +224 -0
  5. kyber/agent/loop.py +687 -0
  6. kyber/agent/memory.py +109 -0
  7. kyber/agent/skills.py +244 -0
  8. kyber/agent/subagent.py +379 -0
  9. kyber/agent/tools/__init__.py +6 -0
  10. kyber/agent/tools/base.py +102 -0
  11. kyber/agent/tools/filesystem.py +191 -0
  12. kyber/agent/tools/message.py +86 -0
  13. kyber/agent/tools/registry.py +73 -0
  14. kyber/agent/tools/shell.py +141 -0
  15. kyber/agent/tools/spawn.py +65 -0
  16. kyber/agent/tools/task_status.py +53 -0
  17. kyber/agent/tools/web.py +163 -0
  18. kyber/bridge/package.json +26 -0
  19. kyber/bridge/src/index.ts +50 -0
  20. kyber/bridge/src/server.ts +104 -0
  21. kyber/bridge/src/types.d.ts +3 -0
  22. kyber/bridge/src/whatsapp.ts +185 -0
  23. kyber/bridge/tsconfig.json +16 -0
  24. kyber/bus/__init__.py +6 -0
  25. kyber/bus/events.py +37 -0
  26. kyber/bus/queue.py +81 -0
  27. kyber/channels/__init__.py +6 -0
  28. kyber/channels/base.py +121 -0
  29. kyber/channels/discord.py +304 -0
  30. kyber/channels/feishu.py +263 -0
  31. kyber/channels/manager.py +161 -0
  32. kyber/channels/telegram.py +302 -0
  33. kyber/channels/whatsapp.py +141 -0
  34. kyber/cli/__init__.py +1 -0
  35. kyber/cli/commands.py +736 -0
  36. kyber/config/__init__.py +6 -0
  37. kyber/config/loader.py +95 -0
  38. kyber/config/schema.py +205 -0
  39. kyber/cron/__init__.py +6 -0
  40. kyber/cron/service.py +346 -0
  41. kyber/cron/types.py +59 -0
  42. kyber/dashboard/__init__.py +5 -0
  43. kyber/dashboard/server.py +122 -0
  44. kyber/dashboard/static/app.js +458 -0
  45. kyber/dashboard/static/favicon.png +0 -0
  46. kyber/dashboard/static/index.html +107 -0
  47. kyber/dashboard/static/kyber_logo.png +0 -0
  48. kyber/dashboard/static/styles.css +608 -0
  49. kyber/heartbeat/__init__.py +5 -0
  50. kyber/heartbeat/service.py +130 -0
  51. kyber/providers/__init__.py +6 -0
  52. kyber/providers/base.py +69 -0
  53. kyber/providers/litellm_provider.py +227 -0
  54. kyber/providers/transcription.py +65 -0
  55. kyber/session/__init__.py +5 -0
  56. kyber/session/manager.py +202 -0
  57. kyber/skills/README.md +47 -0
  58. kyber/skills/github/SKILL.md +48 -0
  59. kyber/skills/skill-creator/SKILL.md +371 -0
  60. kyber/skills/summarize/SKILL.md +67 -0
  61. kyber/skills/tmux/SKILL.md +121 -0
  62. kyber/skills/tmux/scripts/find-sessions.sh +112 -0
  63. kyber/skills/tmux/scripts/wait-for-text.sh +83 -0
  64. kyber/skills/weather/SKILL.md +49 -0
  65. kyber/utils/__init__.py +5 -0
  66. kyber/utils/helpers.py +91 -0
  67. kyber_chat-1.0.0.dist-info/METADATA +35 -0
  68. kyber_chat-1.0.0.dist-info/RECORD +71 -0
  69. kyber_chat-1.0.0.dist-info/WHEEL +4 -0
  70. kyber_chat-1.0.0.dist-info/entry_points.txt +2 -0
  71. kyber_chat-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,304 @@
1
+ """Discord channel implementation using discord.py."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from loguru import logger
8
+
9
+ from kyber.bus.events import OutboundMessage
10
+ from kyber.bus.queue import MessageBus
11
+ from kyber.channels.base import BaseChannel
12
+ from kyber.config.schema import DiscordConfig
13
+
14
+ try:
15
+ import discord
16
+ DISCORD_AVAILABLE = True
17
+ except ImportError:
18
+ DISCORD_AVAILABLE = False
19
+ discord = None
20
+
21
+
22
+ class DiscordChannel(BaseChannel):
23
+ """
24
+ Discord channel implementation.
25
+
26
+ Supports DMs and guild channels. For guilds, it can be configured to
27
+ only respond when mentioned or replied to, to avoid noisy behavior.
28
+ """
29
+
30
+ name = "discord"
31
+
32
+ def __init__(self, config: DiscordConfig, bus: MessageBus):
33
+ super().__init__(config, bus)
34
+ self.config: DiscordConfig = config
35
+ self._client: "discord.Client | None" = None
36
+ self._ready = asyncio.Event()
37
+ self._bot_user_id: int | None = None
38
+ self._typing_tasks: dict[int, asyncio.Task] = {}
39
+ self._typing_counts: dict[int, int] = {}
40
+
41
+ async def start(self) -> None:
42
+ """Start the Discord bot."""
43
+ if not DISCORD_AVAILABLE:
44
+ logger.error("Discord SDK not installed. Run: pip install discord.py")
45
+ return
46
+
47
+ if not self.config.token:
48
+ logger.error("Discord bot token not configured")
49
+ return
50
+
51
+ intents = discord.Intents.default()
52
+ intents.guilds = True
53
+ intents.messages = True
54
+ intents.guild_messages = True
55
+ intents.dm_messages = True
56
+ intents.message_content = True
57
+
58
+ self._client = discord.Client(intents=intents)
59
+
60
+ @self._client.event
61
+ async def on_ready():
62
+ if not self._client or not self._client.user:
63
+ return
64
+ self._bot_user_id = self._client.user.id
65
+ self._ready.set()
66
+ logger.info(f"Discord bot connected as {self._client.user}")
67
+
68
+ @self._client.event
69
+ async def on_message(message: "discord.Message"):
70
+ await self._on_message(message)
71
+
72
+ self._running = True
73
+ try:
74
+ await self._client.start(self.config.token)
75
+ except asyncio.CancelledError:
76
+ pass
77
+ except Exception as e:
78
+ logger.error(f"Discord client error: {e}")
79
+ finally:
80
+ self._running = False
81
+
82
+ async def stop(self) -> None:
83
+ """Stop the Discord bot."""
84
+ self._running = False
85
+ for task in self._typing_tasks.values():
86
+ task.cancel()
87
+ self._typing_tasks.clear()
88
+ self._typing_counts.clear()
89
+ if self._client:
90
+ await self._client.close()
91
+ self._client = None
92
+ self._ready.clear()
93
+
94
+ async def send(self, msg: OutboundMessage) -> None:
95
+ """Send a message through Discord."""
96
+ if not self._client:
97
+ logger.warning("Discord client not initialized")
98
+ return
99
+
100
+ if not self._ready.is_set():
101
+ logger.warning("Discord client not ready yet")
102
+ return
103
+
104
+ try:
105
+ channel_id = int(msg.chat_id)
106
+ except ValueError:
107
+ logger.error(f"Invalid Discord channel id: {msg.chat_id}")
108
+ return
109
+
110
+ # Stop typing indicator for this channel when we send a response
111
+ self._stop_typing(channel_id)
112
+
113
+ try:
114
+ channel = self._client.get_channel(channel_id)
115
+ if channel is None:
116
+ channel = await self._client.fetch_channel(channel_id)
117
+ if channel is None:
118
+ logger.error(f"Discord channel not found: {channel_id}")
119
+ return
120
+
121
+ allowed_mentions = discord.AllowedMentions.none()
122
+ chunks = [chunk for chunk in self._split_message(msg.content) if chunk.strip()]
123
+ if not chunks:
124
+ logger.warning("Skipping empty Discord message")
125
+ return
126
+ for chunk in chunks:
127
+ await channel.send(chunk, allowed_mentions=allowed_mentions)
128
+ except Exception as e:
129
+ logger.error(f"Error sending Discord message: {e}")
130
+
131
+ async def _on_message(self, message: "discord.Message") -> None:
132
+ """Handle incoming messages from Discord."""
133
+ if not self._running:
134
+ return
135
+
136
+ if not message.author or message.author.bot:
137
+ return
138
+
139
+ if self._bot_user_id and message.author.id == self._bot_user_id:
140
+ return
141
+
142
+ if not await self._should_process_message(message):
143
+ return
144
+
145
+ sender_id = str(message.author.id)
146
+ if message.author.name:
147
+ sender_id = f"{sender_id}|{message.author.name}"
148
+
149
+ chat_id = str(message.channel.id)
150
+ is_dm = message.guild is None
151
+
152
+ # Start typing indicator while we process the message
153
+ self._start_typing(message.channel.id, message.channel)
154
+
155
+ content_parts: list[str] = []
156
+ media_paths: list[str] = []
157
+
158
+ if message.content:
159
+ content_parts.append(message.content)
160
+
161
+ if message.stickers:
162
+ sticker_names = ", ".join(s.name for s in message.stickers)
163
+ content_parts.append(f"[sticker: {sticker_names}]")
164
+
165
+ if message.attachments:
166
+ for attachment in message.attachments:
167
+ attachment_path = await self._download_attachment(attachment)
168
+ if attachment_path:
169
+ media_paths.append(str(attachment_path))
170
+ content_parts.append(f"[attachment: {attachment_path}]")
171
+ else:
172
+ content_parts.append(f"[attachment: {attachment.filename} skipped]")
173
+
174
+ content = "\n".join(content_parts) if content_parts else "[empty message]"
175
+
176
+ await self._handle_message(
177
+ sender_id=sender_id,
178
+ chat_id=chat_id,
179
+ content=content,
180
+ media=media_paths,
181
+ metadata={
182
+ "message_id": str(message.id),
183
+ "user_id": str(message.author.id),
184
+ "username": message.author.name,
185
+ "display_name": message.author.display_name,
186
+ "guild_id": str(message.guild.id) if message.guild else None,
187
+ "channel_id": str(message.channel.id),
188
+ "is_dm": is_dm,
189
+ },
190
+ )
191
+
192
+ async def _should_process_message(self, message: "discord.Message") -> bool:
193
+ """Check guild/channel restrictions and mention rules."""
194
+ # Allowlist check via BaseChannel
195
+ sender_id = str(message.author.id)
196
+ if message.author.name:
197
+ sender_id = f"{sender_id}|{message.author.name}"
198
+ if not self.is_allowed(sender_id):
199
+ return False
200
+
201
+ # Restrict by guild/channel if configured
202
+ if message.guild:
203
+ if self.config.allow_guilds and str(message.guild.id) not in self.config.allow_guilds:
204
+ return False
205
+ if self.config.allow_channels and str(message.channel.id) not in self.config.allow_channels:
206
+ return False
207
+
208
+ if self.config.require_mention_in_guilds:
209
+ if self._client and self._client.user in message.mentions:
210
+ return True
211
+ if await self._is_reply_to_bot(message):
212
+ return True
213
+ return False
214
+
215
+ return True
216
+
217
+ async def _is_reply_to_bot(self, message: "discord.Message") -> bool:
218
+ """Return True if the message replies to the bot."""
219
+ if not message.reference:
220
+ return False
221
+
222
+ resolved = message.reference.resolved
223
+ if resolved and hasattr(resolved, "author"):
224
+ return self._bot_user_id is not None and resolved.author.id == self._bot_user_id
225
+
226
+ # If not resolved, avoid extra fetches for safety
227
+ return False
228
+
229
+ def _start_typing(self, channel_id: int, channel: "discord.abc.Messageable") -> None:
230
+ """Start typing indicator for a channel (ref-counted)."""
231
+ if not self.config.typing_indicator:
232
+ return
233
+
234
+ self._typing_counts[channel_id] = self._typing_counts.get(channel_id, 0) + 1
235
+ if channel_id in self._typing_tasks:
236
+ return
237
+
238
+ async def _typing_loop():
239
+ try:
240
+ # Trigger typing periodically while processing
241
+ while self._running:
242
+ async with channel.typing():
243
+ await asyncio.sleep(7)
244
+ except asyncio.CancelledError:
245
+ pass
246
+ except Exception as e:
247
+ logger.debug(f"Typing indicator error: {e}")
248
+
249
+ self._typing_tasks[channel_id] = asyncio.create_task(_typing_loop())
250
+
251
+ def _stop_typing(self, channel_id: int) -> None:
252
+ """Stop typing indicator for a channel (ref-counted)."""
253
+ if channel_id not in self._typing_counts:
254
+ return
255
+
256
+ self._typing_counts[channel_id] = max(0, self._typing_counts[channel_id] - 1)
257
+ if self._typing_counts[channel_id] > 0:
258
+ return
259
+
260
+ self._typing_counts.pop(channel_id, None)
261
+ task = self._typing_tasks.pop(channel_id, None)
262
+ if task:
263
+ task.cancel()
264
+
265
+ async def _download_attachment(self, attachment: "discord.Attachment") -> Path | None:
266
+ """Download an attachment with size limits."""
267
+ max_bytes = max(0, self.config.max_attachment_mb) * 1024 * 1024
268
+ if attachment.size and attachment.size > max_bytes:
269
+ logger.info(
270
+ f"Skipping attachment {attachment.filename} ({attachment.size} bytes) "
271
+ f"over limit {max_bytes} bytes"
272
+ )
273
+ return None
274
+
275
+ try:
276
+ media_dir = Path.home() / ".kyber" / "media"
277
+ media_dir.mkdir(parents=True, exist_ok=True)
278
+
279
+ safe_name = Path(attachment.filename).name
280
+ file_path = media_dir / f"discord_{attachment.id}_{safe_name}"
281
+ await attachment.save(file_path, use_cached=True)
282
+ return file_path
283
+ except Exception as e:
284
+ logger.warning(f"Failed to download attachment {attachment.filename}: {e}")
285
+ return None
286
+
287
+ def _split_message(self, content: str, limit: int = 2000) -> list[str]:
288
+ """Split long messages to fit Discord limits."""
289
+ if not content:
290
+ return []
291
+ if len(content) <= limit:
292
+ return [content]
293
+
294
+ chunks = []
295
+ remaining = content
296
+ while len(remaining) > limit:
297
+ split_at = remaining.rfind("\n", 0, limit)
298
+ if split_at <= 0:
299
+ split_at = limit
300
+ chunks.append(remaining[:split_at])
301
+ remaining = remaining[split_at:].lstrip("\n")
302
+ if remaining:
303
+ chunks.append(remaining)
304
+ return chunks
@@ -0,0 +1,263 @@
1
+ """Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
2
+
3
+ import asyncio
4
+ import json
5
+ import threading
6
+ from collections import OrderedDict
7
+ from typing import Any
8
+
9
+ from loguru import logger
10
+
11
+ from kyber.bus.events import OutboundMessage
12
+ from kyber.bus.queue import MessageBus
13
+ from kyber.channels.base import BaseChannel
14
+ from kyber.config.schema import FeishuConfig
15
+
16
+ try:
17
+ import lark_oapi as lark
18
+ from lark_oapi.api.im.v1 import (
19
+ CreateMessageRequest,
20
+ CreateMessageRequestBody,
21
+ CreateMessageReactionRequest,
22
+ CreateMessageReactionRequestBody,
23
+ Emoji,
24
+ P2ImMessageReceiveV1,
25
+ )
26
+ FEISHU_AVAILABLE = True
27
+ except ImportError:
28
+ FEISHU_AVAILABLE = False
29
+ lark = None
30
+ Emoji = None
31
+
32
+ # Message type display mapping
33
+ MSG_TYPE_MAP = {
34
+ "image": "[image]",
35
+ "audio": "[audio]",
36
+ "file": "[file]",
37
+ "sticker": "[sticker]",
38
+ }
39
+
40
+
41
+ class FeishuChannel(BaseChannel):
42
+ """
43
+ Feishu/Lark channel using WebSocket long connection.
44
+
45
+ Uses WebSocket to receive events - no public IP or webhook required.
46
+
47
+ Requires:
48
+ - App ID and App Secret from Feishu Open Platform
49
+ - Bot capability enabled
50
+ - Event subscription enabled (im.message.receive_v1)
51
+ """
52
+
53
+ name = "feishu"
54
+
55
+ def __init__(self, config: FeishuConfig, bus: MessageBus):
56
+ super().__init__(config, bus)
57
+ self.config: FeishuConfig = config
58
+ self._client: Any = None
59
+ self._ws_client: Any = None
60
+ self._ws_thread: threading.Thread | None = None
61
+ self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
62
+ self._loop: asyncio.AbstractEventLoop | None = None
63
+
64
+ async def start(self) -> None:
65
+ """Start the Feishu bot with WebSocket long connection."""
66
+ if not FEISHU_AVAILABLE:
67
+ logger.error("Feishu SDK not installed. Run: pip install lark-oapi")
68
+ return
69
+
70
+ if not self.config.app_id or not self.config.app_secret:
71
+ logger.error("Feishu app_id and app_secret not configured")
72
+ return
73
+
74
+ self._running = True
75
+ self._loop = asyncio.get_running_loop()
76
+
77
+ # Create Lark client for sending messages
78
+ self._client = lark.Client.builder() \
79
+ .app_id(self.config.app_id) \
80
+ .app_secret(self.config.app_secret) \
81
+ .log_level(lark.LogLevel.INFO) \
82
+ .build()
83
+
84
+ # Create event handler (only register message receive, ignore other events)
85
+ event_handler = lark.EventDispatcherHandler.builder(
86
+ self.config.encrypt_key or "",
87
+ self.config.verification_token or "",
88
+ ).register_p2_im_message_receive_v1(
89
+ self._on_message_sync
90
+ ).build()
91
+
92
+ # Create WebSocket client for long connection
93
+ self._ws_client = lark.ws.Client(
94
+ self.config.app_id,
95
+ self.config.app_secret,
96
+ event_handler=event_handler,
97
+ log_level=lark.LogLevel.INFO
98
+ )
99
+
100
+ # Start WebSocket client in a separate thread
101
+ def run_ws():
102
+ try:
103
+ self._ws_client.start()
104
+ except Exception as e:
105
+ logger.error(f"Feishu WebSocket error: {e}")
106
+
107
+ self._ws_thread = threading.Thread(target=run_ws, daemon=True)
108
+ self._ws_thread.start()
109
+
110
+ logger.info("Feishu bot started with WebSocket long connection")
111
+ logger.info("No public IP required - using WebSocket to receive events")
112
+
113
+ # Keep running until stopped
114
+ while self._running:
115
+ await asyncio.sleep(1)
116
+
117
+ async def stop(self) -> None:
118
+ """Stop the Feishu bot."""
119
+ self._running = False
120
+ if self._ws_client:
121
+ try:
122
+ self._ws_client.stop()
123
+ except Exception as e:
124
+ logger.warning(f"Error stopping WebSocket client: {e}")
125
+ logger.info("Feishu bot stopped")
126
+
127
+ def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None:
128
+ """Sync helper for adding reaction (runs in thread pool)."""
129
+ try:
130
+ request = CreateMessageReactionRequest.builder() \
131
+ .message_id(message_id) \
132
+ .request_body(
133
+ CreateMessageReactionRequestBody.builder()
134
+ .reaction_type(Emoji.builder().emoji_type(emoji_type).build())
135
+ .build()
136
+ ).build()
137
+
138
+ response = self._client.im.v1.message_reaction.create(request)
139
+
140
+ if not response.success():
141
+ logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}")
142
+ else:
143
+ logger.debug(f"Added {emoji_type} reaction to message {message_id}")
144
+ except Exception as e:
145
+ logger.warning(f"Error adding reaction: {e}")
146
+
147
+ async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None:
148
+ """
149
+ Add a reaction emoji to a message (non-blocking).
150
+
151
+ Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
152
+ """
153
+ if not self._client or not Emoji:
154
+ return
155
+
156
+ loop = asyncio.get_running_loop()
157
+ await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type)
158
+
159
+ async def send(self, msg: OutboundMessage) -> None:
160
+ """Send a message through Feishu."""
161
+ if not self._client:
162
+ logger.warning("Feishu client not initialized")
163
+ return
164
+
165
+ try:
166
+ # Determine receive_id_type based on chat_id format
167
+ # open_id starts with "ou_", chat_id starts with "oc_"
168
+ if msg.chat_id.startswith("oc_"):
169
+ receive_id_type = "chat_id"
170
+ else:
171
+ receive_id_type = "open_id"
172
+
173
+ # Build text message content
174
+ content = json.dumps({"text": msg.content})
175
+
176
+ request = CreateMessageRequest.builder() \
177
+ .receive_id_type(receive_id_type) \
178
+ .request_body(
179
+ CreateMessageRequestBody.builder()
180
+ .receive_id(msg.chat_id)
181
+ .msg_type("text")
182
+ .content(content)
183
+ .build()
184
+ ).build()
185
+
186
+ response = self._client.im.v1.message.create(request)
187
+
188
+ if not response.success():
189
+ logger.error(
190
+ f"Failed to send Feishu message: code={response.code}, "
191
+ f"msg={response.msg}, log_id={response.get_log_id()}"
192
+ )
193
+ else:
194
+ logger.debug(f"Feishu message sent to {msg.chat_id}")
195
+
196
+ except Exception as e:
197
+ logger.error(f"Error sending Feishu message: {e}")
198
+
199
+ def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None:
200
+ """
201
+ Sync handler for incoming messages (called from WebSocket thread).
202
+ Schedules async handling in the main event loop.
203
+ """
204
+ if self._loop and self._loop.is_running():
205
+ asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop)
206
+
207
+ async def _on_message(self, data: "P2ImMessageReceiveV1") -> None:
208
+ """Handle incoming message from Feishu."""
209
+ try:
210
+ event = data.event
211
+ message = event.message
212
+ sender = event.sender
213
+
214
+ # Deduplication check
215
+ message_id = message.message_id
216
+ if message_id in self._processed_message_ids:
217
+ return
218
+ self._processed_message_ids[message_id] = None
219
+
220
+ # Trim cache: keep most recent 500 when exceeds 1000
221
+ while len(self._processed_message_ids) > 1000:
222
+ self._processed_message_ids.popitem(last=False)
223
+
224
+ # Skip bot messages
225
+ sender_type = sender.sender_type
226
+ if sender_type == "bot":
227
+ return
228
+
229
+ sender_id = sender.sender_id.open_id if sender.sender_id else "unknown"
230
+ chat_id = message.chat_id
231
+ chat_type = message.chat_type # "p2p" or "group"
232
+ msg_type = message.message_type
233
+
234
+ # Add reaction to indicate "seen"
235
+ await self._add_reaction(message_id, "THUMBSUP")
236
+
237
+ # Parse message content
238
+ if msg_type == "text":
239
+ try:
240
+ content = json.loads(message.content).get("text", "")
241
+ except json.JSONDecodeError:
242
+ content = message.content or ""
243
+ else:
244
+ content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")
245
+
246
+ if not content:
247
+ return
248
+
249
+ # Forward to message bus
250
+ reply_to = chat_id if chat_type == "group" else sender_id
251
+ await self._handle_message(
252
+ sender_id=sender_id,
253
+ chat_id=reply_to,
254
+ content=content,
255
+ metadata={
256
+ "message_id": message_id,
257
+ "chat_type": chat_type,
258
+ "msg_type": msg_type,
259
+ }
260
+ )
261
+
262
+ except Exception as e:
263
+ logger.error(f"Error processing Feishu message: {e}")