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,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
|
kyber/channels/feishu.py
ADDED
|
@@ -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}")
|