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,161 @@
1
+ """Channel manager for coordinating chat channels."""
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ from loguru import logger
7
+
8
+ from kyber.bus.events import OutboundMessage
9
+ from kyber.bus.queue import MessageBus
10
+ from kyber.channels.base import BaseChannel
11
+ from kyber.config.schema import Config
12
+
13
+
14
+ class ChannelManager:
15
+ """
16
+ Manages chat channels and coordinates message routing.
17
+
18
+ Responsibilities:
19
+ - Initialize enabled channels (Telegram, WhatsApp, etc.)
20
+ - Start/stop channels
21
+ - Route outbound messages
22
+ """
23
+
24
+ def __init__(self, config: Config, bus: MessageBus):
25
+ self.config = config
26
+ self.bus = bus
27
+ self.channels: dict[str, BaseChannel] = {}
28
+ self._dispatch_task: asyncio.Task | None = None
29
+
30
+ self._init_channels()
31
+
32
+ def _init_channels(self) -> None:
33
+ """Initialize channels based on config."""
34
+
35
+ # Telegram channel
36
+ if self.config.channels.telegram.enabled:
37
+ try:
38
+ from kyber.channels.telegram import TelegramChannel
39
+ self.channels["telegram"] = TelegramChannel(
40
+ self.config.channels.telegram,
41
+ self.bus,
42
+ groq_api_key=self.config.providers.groq.api_key,
43
+ )
44
+ logger.info("Telegram channel enabled")
45
+ except ImportError as e:
46
+ logger.warning(f"Telegram channel not available: {e}")
47
+
48
+ # WhatsApp channel
49
+ if self.config.channels.whatsapp.enabled:
50
+ try:
51
+ from kyber.channels.whatsapp import WhatsAppChannel
52
+ self.channels["whatsapp"] = WhatsAppChannel(
53
+ self.config.channels.whatsapp, self.bus
54
+ )
55
+ logger.info("WhatsApp channel enabled")
56
+ except ImportError as e:
57
+ logger.warning(f"WhatsApp channel not available: {e}")
58
+
59
+ # Feishu channel
60
+ if self.config.channels.feishu.enabled:
61
+ try:
62
+ from kyber.channels.feishu import FeishuChannel
63
+ self.channels["feishu"] = FeishuChannel(
64
+ self.config.channels.feishu, self.bus
65
+ )
66
+ logger.info("Feishu channel enabled")
67
+ except ImportError as e:
68
+ logger.warning(f"Feishu channel not available: {e}")
69
+
70
+ # Discord channel
71
+ if self.config.channels.discord.enabled:
72
+ try:
73
+ from kyber.channels.discord import DiscordChannel
74
+ self.channels["discord"] = DiscordChannel(
75
+ self.config.channels.discord, self.bus
76
+ )
77
+ logger.info("Discord channel enabled")
78
+ except ImportError as e:
79
+ logger.warning(f"Discord channel not available: {e}")
80
+
81
+ async def start_all(self) -> None:
82
+ """Start WhatsApp channel and the outbound dispatcher."""
83
+ if not self.channels:
84
+ logger.warning("No channels enabled")
85
+ return
86
+
87
+ # Start outbound dispatcher
88
+ self._dispatch_task = asyncio.create_task(self._dispatch_outbound())
89
+
90
+ # Start WhatsApp channel
91
+ tasks = []
92
+ for name, channel in self.channels.items():
93
+ logger.info(f"Starting {name} channel...")
94
+ tasks.append(asyncio.create_task(channel.start()))
95
+
96
+ # Wait for all to complete (they should run forever)
97
+ await asyncio.gather(*tasks, return_exceptions=True)
98
+
99
+ async def stop_all(self) -> None:
100
+ """Stop all channels and the dispatcher."""
101
+ logger.info("Stopping all channels...")
102
+
103
+ # Stop dispatcher
104
+ if self._dispatch_task:
105
+ self._dispatch_task.cancel()
106
+ try:
107
+ await self._dispatch_task
108
+ except asyncio.CancelledError:
109
+ pass
110
+
111
+ # Stop all channels
112
+ for name, channel in self.channels.items():
113
+ try:
114
+ await channel.stop()
115
+ logger.info(f"Stopped {name} channel")
116
+ except Exception as e:
117
+ logger.error(f"Error stopping {name}: {e}")
118
+
119
+ async def _dispatch_outbound(self) -> None:
120
+ """Dispatch outbound messages to the appropriate channel."""
121
+ logger.info("Outbound dispatcher started")
122
+
123
+ while True:
124
+ try:
125
+ msg = await asyncio.wait_for(
126
+ self.bus.consume_outbound(),
127
+ timeout=1.0
128
+ )
129
+
130
+ channel = self.channels.get(msg.channel)
131
+ if channel:
132
+ try:
133
+ await channel.send(msg)
134
+ except Exception as e:
135
+ logger.error(f"Error sending to {msg.channel}: {e}")
136
+ else:
137
+ logger.warning(f"Unknown channel: {msg.channel}")
138
+
139
+ except asyncio.TimeoutError:
140
+ continue
141
+ except asyncio.CancelledError:
142
+ break
143
+
144
+ def get_channel(self, name: str) -> BaseChannel | None:
145
+ """Get a channel by name."""
146
+ return self.channels.get(name)
147
+
148
+ def get_status(self) -> dict[str, Any]:
149
+ """Get status of all channels."""
150
+ return {
151
+ name: {
152
+ "enabled": True,
153
+ "running": channel.is_running
154
+ }
155
+ for name, channel in self.channels.items()
156
+ }
157
+
158
+ @property
159
+ def enabled_channels(self) -> list[str]:
160
+ """Get list of enabled channel names."""
161
+ return list(self.channels.keys())
@@ -0,0 +1,302 @@
1
+ """Telegram channel implementation using python-telegram-bot."""
2
+
3
+ import asyncio
4
+ import re
5
+
6
+ from loguru import logger
7
+ from telegram import Update
8
+ from telegram.ext import Application, MessageHandler, filters, ContextTypes
9
+
10
+ from kyber.bus.events import OutboundMessage
11
+ from kyber.bus.queue import MessageBus
12
+ from kyber.channels.base import BaseChannel
13
+ from kyber.config.schema import TelegramConfig
14
+
15
+
16
+ def _markdown_to_telegram_html(text: str) -> str:
17
+ """
18
+ Convert markdown to Telegram-safe HTML.
19
+ """
20
+ if not text:
21
+ return ""
22
+
23
+ # 1. Extract and protect code blocks (preserve content from other processing)
24
+ code_blocks: list[str] = []
25
+ def save_code_block(m: re.Match) -> str:
26
+ code_blocks.append(m.group(1))
27
+ return f"\x00CB{len(code_blocks) - 1}\x00"
28
+
29
+ text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text)
30
+
31
+ # 2. Extract and protect inline code
32
+ inline_codes: list[str] = []
33
+ def save_inline_code(m: re.Match) -> str:
34
+ inline_codes.append(m.group(1))
35
+ return f"\x00IC{len(inline_codes) - 1}\x00"
36
+
37
+ text = re.sub(r'`([^`]+)`', save_inline_code, text)
38
+
39
+ # 3. Headers # Title -> just the title text
40
+ text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE)
41
+
42
+ # 4. Blockquotes > text -> just the text (before HTML escaping)
43
+ text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE)
44
+
45
+ # 5. Escape HTML special characters
46
+ text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
47
+
48
+ # 6. Links [text](url) - must be before bold/italic to handle nested cases
49
+ text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
50
+
51
+ # 7. Bold **text** or __text__
52
+ text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
53
+ text = re.sub(r'__(.+?)__', r'<b>\1</b>', text)
54
+
55
+ # 8. Italic _text_ (avoid matching inside words like some_var_name)
56
+ text = re.sub(r'(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])', r'<i>\1</i>', text)
57
+
58
+ # 9. Strikethrough ~~text~~
59
+ text = re.sub(r'~~(.+?)~~', r'<s>\1</s>', text)
60
+
61
+ # 10. Bullet lists - item -> • item
62
+ text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE)
63
+
64
+ # 11. Restore inline code with HTML tags
65
+ for i, code in enumerate(inline_codes):
66
+ # Escape HTML in code content
67
+ escaped = code.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
68
+ text = text.replace(f"\x00IC{i}\x00", f"<code>{escaped}</code>")
69
+
70
+ # 12. Restore code blocks with HTML tags
71
+ for i, code in enumerate(code_blocks):
72
+ # Escape HTML in code content
73
+ escaped = code.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
74
+ text = text.replace(f"\x00CB{i}\x00", f"<pre><code>{escaped}</code></pre>")
75
+
76
+ return text
77
+
78
+
79
+ class TelegramChannel(BaseChannel):
80
+ """
81
+ Telegram channel using long polling.
82
+
83
+ Simple and reliable - no webhook/public IP needed.
84
+ """
85
+
86
+ name = "telegram"
87
+
88
+ def __init__(self, config: TelegramConfig, bus: MessageBus, groq_api_key: str = ""):
89
+ super().__init__(config, bus)
90
+ self.config: TelegramConfig = config
91
+ self.groq_api_key = groq_api_key
92
+ self._app: Application | None = None
93
+ self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
94
+
95
+ async def start(self) -> None:
96
+ """Start the Telegram bot with long polling."""
97
+ if not self.config.token:
98
+ logger.error("Telegram bot token not configured")
99
+ return
100
+
101
+ self._running = True
102
+
103
+ # Build the application
104
+ self._app = (
105
+ Application.builder()
106
+ .token(self.config.token)
107
+ .build()
108
+ )
109
+
110
+ # Add message handler for text, photos, voice, documents
111
+ self._app.add_handler(
112
+ MessageHandler(
113
+ (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL)
114
+ & ~filters.COMMAND,
115
+ self._on_message
116
+ )
117
+ )
118
+
119
+ # Add /start command handler
120
+ from telegram.ext import CommandHandler
121
+ self._app.add_handler(CommandHandler("start", self._on_start))
122
+
123
+ logger.info("Starting Telegram bot (polling mode)...")
124
+
125
+ # Initialize and start polling
126
+ await self._app.initialize()
127
+ await self._app.start()
128
+
129
+ # Get bot info
130
+ bot_info = await self._app.bot.get_me()
131
+ logger.info(f"Telegram bot @{bot_info.username} connected")
132
+
133
+ # Start polling (this runs until stopped)
134
+ await self._app.updater.start_polling(
135
+ allowed_updates=["message"],
136
+ drop_pending_updates=True # Ignore old messages on startup
137
+ )
138
+
139
+ # Keep running until stopped
140
+ while self._running:
141
+ await asyncio.sleep(1)
142
+
143
+ async def stop(self) -> None:
144
+ """Stop the Telegram bot."""
145
+ self._running = False
146
+
147
+ if self._app:
148
+ logger.info("Stopping Telegram bot...")
149
+ await self._app.updater.stop()
150
+ await self._app.stop()
151
+ await self._app.shutdown()
152
+ self._app = None
153
+
154
+ async def send(self, msg: OutboundMessage) -> None:
155
+ """Send a message through Telegram."""
156
+ if not self._app:
157
+ logger.warning("Telegram bot not running")
158
+ return
159
+
160
+ try:
161
+ # chat_id should be the Telegram chat ID (integer)
162
+ chat_id = int(msg.chat_id)
163
+ # Convert markdown to Telegram HTML
164
+ html_content = _markdown_to_telegram_html(msg.content)
165
+ await self._app.bot.send_message(
166
+ chat_id=chat_id,
167
+ text=html_content,
168
+ parse_mode="HTML"
169
+ )
170
+ except ValueError:
171
+ logger.error(f"Invalid chat_id: {msg.chat_id}")
172
+ except Exception as e:
173
+ # Fallback to plain text if HTML parsing fails
174
+ logger.warning(f"HTML parse failed, falling back to plain text: {e}")
175
+ try:
176
+ await self._app.bot.send_message(
177
+ chat_id=int(msg.chat_id),
178
+ text=msg.content
179
+ )
180
+ except Exception as e2:
181
+ logger.error(f"Error sending Telegram message: {e2}")
182
+
183
+ async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
184
+ """Handle /start command."""
185
+ if not update.message or not update.effective_user:
186
+ return
187
+
188
+ user = update.effective_user
189
+ await update.message.reply_text(
190
+ f"👋 Hi {user.first_name}! I'm kyber.\n\n"
191
+ "Send me a message and I'll respond!"
192
+ )
193
+
194
+ async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
195
+ """Handle incoming messages (text, photos, voice, documents)."""
196
+ if not update.message or not update.effective_user:
197
+ return
198
+
199
+ message = update.message
200
+ user = update.effective_user
201
+ chat_id = message.chat_id
202
+
203
+ # Use stable numeric ID, but keep username for allowlist compatibility
204
+ sender_id = str(user.id)
205
+ if user.username:
206
+ sender_id = f"{sender_id}|{user.username}"
207
+
208
+ # Store chat_id for replies
209
+ self._chat_ids[sender_id] = chat_id
210
+
211
+ # Build content from text and/or media
212
+ content_parts = []
213
+ media_paths = []
214
+
215
+ # Text content
216
+ if message.text:
217
+ content_parts.append(message.text)
218
+ if message.caption:
219
+ content_parts.append(message.caption)
220
+
221
+ # Handle media files
222
+ media_file = None
223
+ media_type = None
224
+
225
+ if message.photo:
226
+ media_file = message.photo[-1] # Largest photo
227
+ media_type = "image"
228
+ elif message.voice:
229
+ media_file = message.voice
230
+ media_type = "voice"
231
+ elif message.audio:
232
+ media_file = message.audio
233
+ media_type = "audio"
234
+ elif message.document:
235
+ media_file = message.document
236
+ media_type = "file"
237
+
238
+ # Download media if present
239
+ if media_file and self._app:
240
+ try:
241
+ file = await self._app.bot.get_file(media_file.file_id)
242
+ ext = self._get_extension(media_type, getattr(media_file, 'mime_type', None))
243
+
244
+ # Save to workspace/media/
245
+ from pathlib import Path
246
+ media_dir = Path.home() / ".kyber" / "media"
247
+ media_dir.mkdir(parents=True, exist_ok=True)
248
+
249
+ file_path = media_dir / f"{media_file.file_id[:16]}{ext}"
250
+ await file.download_to_drive(str(file_path))
251
+
252
+ media_paths.append(str(file_path))
253
+
254
+ # Handle voice transcription
255
+ if media_type == "voice" or media_type == "audio":
256
+ from kyber.providers.transcription import GroqTranscriptionProvider
257
+ transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key)
258
+ transcription = await transcriber.transcribe(file_path)
259
+ if transcription:
260
+ logger.info(f"Transcribed {media_type}: {transcription[:50]}...")
261
+ content_parts.append(f"[transcription: {transcription}]")
262
+ else:
263
+ content_parts.append(f"[{media_type}: {file_path}]")
264
+ else:
265
+ content_parts.append(f"[{media_type}: {file_path}]")
266
+
267
+ logger.debug(f"Downloaded {media_type} to {file_path}")
268
+ except Exception as e:
269
+ logger.error(f"Failed to download media: {e}")
270
+ content_parts.append(f"[{media_type}: download failed]")
271
+
272
+ content = "\n".join(content_parts) if content_parts else "[empty message]"
273
+
274
+ logger.debug(f"Telegram message from {sender_id}: {content[:50]}...")
275
+
276
+ # Forward to the message bus
277
+ await self._handle_message(
278
+ sender_id=sender_id,
279
+ chat_id=str(chat_id),
280
+ content=content,
281
+ media=media_paths,
282
+ metadata={
283
+ "message_id": message.message_id,
284
+ "user_id": user.id,
285
+ "username": user.username,
286
+ "first_name": user.first_name,
287
+ "is_group": message.chat.type != "private"
288
+ }
289
+ )
290
+
291
+ def _get_extension(self, media_type: str, mime_type: str | None) -> str:
292
+ """Get file extension based on media type."""
293
+ if mime_type:
294
+ ext_map = {
295
+ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif",
296
+ "audio/ogg": ".ogg", "audio/mpeg": ".mp3", "audio/mp4": ".m4a",
297
+ }
298
+ if mime_type in ext_map:
299
+ return ext_map[mime_type]
300
+
301
+ type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""}
302
+ return type_map.get(media_type, "")
@@ -0,0 +1,141 @@
1
+ """WhatsApp channel implementation using Node.js bridge."""
2
+
3
+ import asyncio
4
+ import json
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 WhatsAppConfig
13
+
14
+
15
+ class WhatsAppChannel(BaseChannel):
16
+ """
17
+ WhatsApp channel that connects to a Node.js bridge.
18
+
19
+ The bridge uses @whiskeysockets/baileys to handle the WhatsApp Web protocol.
20
+ Communication between Python and Node.js is via WebSocket.
21
+ """
22
+
23
+ name = "whatsapp"
24
+
25
+ def __init__(self, config: WhatsAppConfig, bus: MessageBus):
26
+ super().__init__(config, bus)
27
+ self.config: WhatsAppConfig = config
28
+ self._ws = None
29
+ self._connected = False
30
+
31
+ async def start(self) -> None:
32
+ """Start the WhatsApp channel by connecting to the bridge."""
33
+ import websockets
34
+
35
+ bridge_url = self.config.bridge_url
36
+
37
+ logger.info(f"Connecting to WhatsApp bridge at {bridge_url}...")
38
+
39
+ self._running = True
40
+
41
+ while self._running:
42
+ try:
43
+ async with websockets.connect(bridge_url) as ws:
44
+ self._ws = ws
45
+ self._connected = True
46
+ logger.info("Connected to WhatsApp bridge")
47
+
48
+ # Listen for messages
49
+ async for message in ws:
50
+ try:
51
+ await self._handle_bridge_message(message)
52
+ except Exception as e:
53
+ logger.error(f"Error handling bridge message: {e}")
54
+
55
+ except asyncio.CancelledError:
56
+ break
57
+ except Exception as e:
58
+ self._connected = False
59
+ self._ws = None
60
+ logger.warning(f"WhatsApp bridge connection error: {e}")
61
+
62
+ if self._running:
63
+ logger.info("Reconnecting in 5 seconds...")
64
+ await asyncio.sleep(5)
65
+
66
+ async def stop(self) -> None:
67
+ """Stop the WhatsApp channel."""
68
+ self._running = False
69
+ self._connected = False
70
+
71
+ if self._ws:
72
+ await self._ws.close()
73
+ self._ws = None
74
+
75
+ async def send(self, msg: OutboundMessage) -> None:
76
+ """Send a message through WhatsApp."""
77
+ if not self._ws or not self._connected:
78
+ logger.warning("WhatsApp bridge not connected")
79
+ return
80
+
81
+ try:
82
+ payload = {
83
+ "type": "send",
84
+ "to": msg.chat_id,
85
+ "text": msg.content
86
+ }
87
+ await self._ws.send(json.dumps(payload))
88
+ except Exception as e:
89
+ logger.error(f"Error sending WhatsApp message: {e}")
90
+
91
+ async def _handle_bridge_message(self, raw: str) -> None:
92
+ """Handle a message from the bridge."""
93
+ try:
94
+ data = json.loads(raw)
95
+ except json.JSONDecodeError:
96
+ logger.warning(f"Invalid JSON from bridge: {raw[:100]}")
97
+ return
98
+
99
+ msg_type = data.get("type")
100
+
101
+ if msg_type == "message":
102
+ # Incoming message from WhatsApp
103
+ sender = data.get("sender", "")
104
+ content = data.get("content", "")
105
+
106
+ # sender is typically: <phone>@s.whatsapp.net
107
+ # Extract just the phone number as chat_id
108
+ chat_id = sender.split("@")[0] if "@" in sender else sender
109
+
110
+ # Handle voice transcription if it's a voice message
111
+ if content == "[Voice Message]":
112
+ logger.info(f"Voice message received from {chat_id}, but direct download from bridge is not yet supported.")
113
+ content = "[Voice Message: Transcription not available for WhatsApp yet]"
114
+
115
+ await self._handle_message(
116
+ sender_id=chat_id,
117
+ chat_id=sender, # Use full JID for replies
118
+ content=content,
119
+ metadata={
120
+ "message_id": data.get("id"),
121
+ "timestamp": data.get("timestamp"),
122
+ "is_group": data.get("isGroup", False)
123
+ }
124
+ )
125
+
126
+ elif msg_type == "status":
127
+ # Connection status update
128
+ status = data.get("status")
129
+ logger.info(f"WhatsApp status: {status}")
130
+
131
+ if status == "connected":
132
+ self._connected = True
133
+ elif status == "disconnected":
134
+ self._connected = False
135
+
136
+ elif msg_type == "qr":
137
+ # QR code for authentication
138
+ logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
139
+
140
+ elif msg_type == "error":
141
+ logger.error(f"WhatsApp bridge error: {data.get('error')}")
kyber/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI module for kyber."""