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.
- kyber/__init__.py +6 -0
- kyber/__main__.py +8 -0
- kyber/agent/__init__.py +8 -0
- kyber/agent/context.py +224 -0
- kyber/agent/loop.py +687 -0
- kyber/agent/memory.py +109 -0
- kyber/agent/skills.py +244 -0
- kyber/agent/subagent.py +379 -0
- kyber/agent/tools/__init__.py +6 -0
- kyber/agent/tools/base.py +102 -0
- kyber/agent/tools/filesystem.py +191 -0
- kyber/agent/tools/message.py +86 -0
- kyber/agent/tools/registry.py +73 -0
- kyber/agent/tools/shell.py +141 -0
- kyber/agent/tools/spawn.py +65 -0
- kyber/agent/tools/task_status.py +53 -0
- kyber/agent/tools/web.py +163 -0
- kyber/bridge/package.json +26 -0
- kyber/bridge/src/index.ts +50 -0
- kyber/bridge/src/server.ts +104 -0
- kyber/bridge/src/types.d.ts +3 -0
- kyber/bridge/src/whatsapp.ts +185 -0
- kyber/bridge/tsconfig.json +16 -0
- kyber/bus/__init__.py +6 -0
- kyber/bus/events.py +37 -0
- kyber/bus/queue.py +81 -0
- kyber/channels/__init__.py +6 -0
- kyber/channels/base.py +121 -0
- kyber/channels/discord.py +304 -0
- kyber/channels/feishu.py +263 -0
- kyber/channels/manager.py +161 -0
- kyber/channels/telegram.py +302 -0
- kyber/channels/whatsapp.py +141 -0
- kyber/cli/__init__.py +1 -0
- kyber/cli/commands.py +736 -0
- kyber/config/__init__.py +6 -0
- kyber/config/loader.py +95 -0
- kyber/config/schema.py +205 -0
- kyber/cron/__init__.py +6 -0
- kyber/cron/service.py +346 -0
- kyber/cron/types.py +59 -0
- kyber/dashboard/__init__.py +5 -0
- kyber/dashboard/server.py +122 -0
- kyber/dashboard/static/app.js +458 -0
- kyber/dashboard/static/favicon.png +0 -0
- kyber/dashboard/static/index.html +107 -0
- kyber/dashboard/static/kyber_logo.png +0 -0
- kyber/dashboard/static/styles.css +608 -0
- kyber/heartbeat/__init__.py +5 -0
- kyber/heartbeat/service.py +130 -0
- kyber/providers/__init__.py +6 -0
- kyber/providers/base.py +69 -0
- kyber/providers/litellm_provider.py +227 -0
- kyber/providers/transcription.py +65 -0
- kyber/session/__init__.py +5 -0
- kyber/session/manager.py +202 -0
- kyber/skills/README.md +47 -0
- kyber/skills/github/SKILL.md +48 -0
- kyber/skills/skill-creator/SKILL.md +371 -0
- kyber/skills/summarize/SKILL.md +67 -0
- kyber/skills/tmux/SKILL.md +121 -0
- kyber/skills/tmux/scripts/find-sessions.sh +112 -0
- kyber/skills/tmux/scripts/wait-for-text.sh +83 -0
- kyber/skills/weather/SKILL.md +49 -0
- kyber/utils/__init__.py +5 -0
- kyber/utils/helpers.py +91 -0
- kyber_chat-1.0.0.dist-info/METADATA +35 -0
- kyber_chat-1.0.0.dist-info/RECORD +71 -0
- kyber_chat-1.0.0.dist-info/WHEEL +4 -0
- kyber_chat-1.0.0.dist-info/entry_points.txt +2 -0
- 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("&", "&").replace("<", "<").replace(">", ">")
|
|
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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."""
|