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,86 @@
|
|
|
1
|
+
"""Message tool for sending messages to users."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Awaitable
|
|
4
|
+
|
|
5
|
+
from kyber.agent.tools.base import Tool
|
|
6
|
+
from kyber.bus.events import OutboundMessage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MessageTool(Tool):
|
|
10
|
+
"""Tool to send messages to users on chat channels."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
|
15
|
+
default_channel: str = "",
|
|
16
|
+
default_chat_id: str = ""
|
|
17
|
+
):
|
|
18
|
+
self._send_callback = send_callback
|
|
19
|
+
self._default_channel = default_channel
|
|
20
|
+
self._default_chat_id = default_chat_id
|
|
21
|
+
|
|
22
|
+
def set_context(self, channel: str, chat_id: str) -> None:
|
|
23
|
+
"""Set the current message context."""
|
|
24
|
+
self._default_channel = channel
|
|
25
|
+
self._default_chat_id = chat_id
|
|
26
|
+
|
|
27
|
+
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
|
|
28
|
+
"""Set the callback for sending messages."""
|
|
29
|
+
self._send_callback = callback
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def name(self) -> str:
|
|
33
|
+
return "message"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def description(self) -> str:
|
|
37
|
+
return "Send a message to the user. Use this when you want to communicate something."
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def parameters(self) -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"content": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "The message content to send"
|
|
47
|
+
},
|
|
48
|
+
"channel": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"description": "Optional: target channel (telegram, discord, etc.)"
|
|
51
|
+
},
|
|
52
|
+
"chat_id": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "Optional: target chat/user ID"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"required": ["content"]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async def execute(
|
|
61
|
+
self,
|
|
62
|
+
content: str,
|
|
63
|
+
channel: str | None = None,
|
|
64
|
+
chat_id: str | None = None,
|
|
65
|
+
**kwargs: Any
|
|
66
|
+
) -> str:
|
|
67
|
+
channel = channel or self._default_channel
|
|
68
|
+
chat_id = chat_id or self._default_chat_id
|
|
69
|
+
|
|
70
|
+
if not channel or not chat_id:
|
|
71
|
+
return "Error: No target channel/chat specified"
|
|
72
|
+
|
|
73
|
+
if not self._send_callback:
|
|
74
|
+
return "Error: Message sending not configured"
|
|
75
|
+
|
|
76
|
+
msg = OutboundMessage(
|
|
77
|
+
channel=channel,
|
|
78
|
+
chat_id=chat_id,
|
|
79
|
+
content=content
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
await self._send_callback(msg)
|
|
84
|
+
return f"Message sent to {channel}:{chat_id}"
|
|
85
|
+
except Exception as e:
|
|
86
|
+
return f"Error sending message: {str(e)}"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Tool registry for dynamic tool management."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from kyber.agent.tools.base import Tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolRegistry:
|
|
9
|
+
"""
|
|
10
|
+
Registry for agent tools.
|
|
11
|
+
|
|
12
|
+
Allows dynamic registration and execution of tools.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self._tools: dict[str, Tool] = {}
|
|
17
|
+
|
|
18
|
+
def register(self, tool: Tool) -> None:
|
|
19
|
+
"""Register a tool."""
|
|
20
|
+
self._tools[tool.name] = tool
|
|
21
|
+
|
|
22
|
+
def unregister(self, name: str) -> None:
|
|
23
|
+
"""Unregister a tool by name."""
|
|
24
|
+
self._tools.pop(name, None)
|
|
25
|
+
|
|
26
|
+
def get(self, name: str) -> Tool | None:
|
|
27
|
+
"""Get a tool by name."""
|
|
28
|
+
return self._tools.get(name)
|
|
29
|
+
|
|
30
|
+
def has(self, name: str) -> bool:
|
|
31
|
+
"""Check if a tool is registered."""
|
|
32
|
+
return name in self._tools
|
|
33
|
+
|
|
34
|
+
def get_definitions(self) -> list[dict[str, Any]]:
|
|
35
|
+
"""Get all tool definitions in OpenAI format."""
|
|
36
|
+
return [tool.to_schema() for tool in self._tools.values()]
|
|
37
|
+
|
|
38
|
+
async def execute(self, name: str, params: dict[str, Any]) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Execute a tool by name with given parameters.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
name: Tool name.
|
|
44
|
+
params: Tool parameters.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tool execution result as string.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
KeyError: If tool not found.
|
|
51
|
+
"""
|
|
52
|
+
tool = self._tools.get(name)
|
|
53
|
+
if not tool:
|
|
54
|
+
return f"Error: Tool '{name}' not found"
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
errors = tool.validate_params(params)
|
|
58
|
+
if errors:
|
|
59
|
+
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
|
|
60
|
+
return await tool.execute(**params)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return f"Error executing {name}: {str(e)}"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def tool_names(self) -> list[str]:
|
|
66
|
+
"""Get list of registered tool names."""
|
|
67
|
+
return list(self._tools.keys())
|
|
68
|
+
|
|
69
|
+
def __len__(self) -> int:
|
|
70
|
+
return len(self._tools)
|
|
71
|
+
|
|
72
|
+
def __contains__(self, name: str) -> bool:
|
|
73
|
+
return name in self._tools
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Shell execution tool."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from kyber.agent.tools.base import Tool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExecTool(Tool):
|
|
13
|
+
"""Tool to execute shell commands."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
timeout: int = 60,
|
|
18
|
+
working_dir: str | None = None,
|
|
19
|
+
deny_patterns: list[str] | None = None,
|
|
20
|
+
allow_patterns: list[str] | None = None,
|
|
21
|
+
restrict_to_workspace: bool = False,
|
|
22
|
+
):
|
|
23
|
+
self.timeout = timeout
|
|
24
|
+
self.working_dir = working_dir
|
|
25
|
+
self.deny_patterns = deny_patterns or [
|
|
26
|
+
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
|
27
|
+
r"\bdel\s+/[fq]\b", # del /f, del /q
|
|
28
|
+
r"\brmdir\s+/s\b", # rmdir /s
|
|
29
|
+
r"\b(format|mkfs|diskpart)\b", # disk operations
|
|
30
|
+
r"\bdd\s+if=", # dd
|
|
31
|
+
r">\s*/dev/sd", # write to disk
|
|
32
|
+
r"\b(shutdown|reboot|poweroff)\b", # system power
|
|
33
|
+
r":\(\)\s*\{.*\};\s*:", # fork bomb
|
|
34
|
+
]
|
|
35
|
+
self.allow_patterns = allow_patterns or []
|
|
36
|
+
self.restrict_to_workspace = restrict_to_workspace
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
return "exec"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def description(self) -> str:
|
|
44
|
+
return "Execute a shell command and return its output. Use with caution."
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def parameters(self) -> dict[str, Any]:
|
|
48
|
+
return {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"command": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "The shell command to execute"
|
|
54
|
+
},
|
|
55
|
+
"working_dir": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "Optional working directory for the command"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"required": ["command"]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
|
|
64
|
+
cwd = working_dir or self.working_dir or os.getcwd()
|
|
65
|
+
guard_error = self._guard_command(command, cwd)
|
|
66
|
+
if guard_error:
|
|
67
|
+
return guard_error
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
process = await asyncio.create_subprocess_shell(
|
|
71
|
+
command,
|
|
72
|
+
stdout=asyncio.subprocess.PIPE,
|
|
73
|
+
stderr=asyncio.subprocess.PIPE,
|
|
74
|
+
cwd=cwd,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
stdout, stderr = await asyncio.wait_for(
|
|
79
|
+
process.communicate(),
|
|
80
|
+
timeout=self.timeout
|
|
81
|
+
)
|
|
82
|
+
except asyncio.TimeoutError:
|
|
83
|
+
process.kill()
|
|
84
|
+
return f"Error: Command timed out after {self.timeout} seconds"
|
|
85
|
+
|
|
86
|
+
output_parts = []
|
|
87
|
+
|
|
88
|
+
if stdout:
|
|
89
|
+
output_parts.append(stdout.decode("utf-8", errors="replace"))
|
|
90
|
+
|
|
91
|
+
if stderr:
|
|
92
|
+
stderr_text = stderr.decode("utf-8", errors="replace")
|
|
93
|
+
if stderr_text.strip():
|
|
94
|
+
output_parts.append(f"STDERR:\n{stderr_text}")
|
|
95
|
+
|
|
96
|
+
if process.returncode != 0:
|
|
97
|
+
output_parts.append(f"\nExit code: {process.returncode}")
|
|
98
|
+
|
|
99
|
+
result = "\n".join(output_parts) if output_parts else "(no output)"
|
|
100
|
+
|
|
101
|
+
# Truncate very long output
|
|
102
|
+
max_len = 10000
|
|
103
|
+
if len(result) > max_len:
|
|
104
|
+
result = result[:max_len] + f"\n... (truncated, {len(result) - max_len} more chars)"
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
except Exception as e:
|
|
109
|
+
return f"Error executing command: {str(e)}"
|
|
110
|
+
|
|
111
|
+
def _guard_command(self, command: str, cwd: str) -> str | None:
|
|
112
|
+
"""Best-effort safety guard for potentially destructive commands."""
|
|
113
|
+
cmd = command.strip()
|
|
114
|
+
lower = cmd.lower()
|
|
115
|
+
|
|
116
|
+
for pattern in self.deny_patterns:
|
|
117
|
+
if re.search(pattern, lower):
|
|
118
|
+
return "Error: Command blocked by safety guard (dangerous pattern detected)"
|
|
119
|
+
|
|
120
|
+
if self.allow_patterns:
|
|
121
|
+
if not any(re.search(p, lower) for p in self.allow_patterns):
|
|
122
|
+
return "Error: Command blocked by safety guard (not in allowlist)"
|
|
123
|
+
|
|
124
|
+
if self.restrict_to_workspace:
|
|
125
|
+
if "..\\" in cmd or "../" in cmd:
|
|
126
|
+
return "Error: Command blocked by safety guard (path traversal detected)"
|
|
127
|
+
|
|
128
|
+
cwd_path = Path(cwd).resolve()
|
|
129
|
+
|
|
130
|
+
win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd)
|
|
131
|
+
posix_paths = re.findall(r"/[^\s\"']+", cmd)
|
|
132
|
+
|
|
133
|
+
for raw in win_paths + posix_paths:
|
|
134
|
+
try:
|
|
135
|
+
p = Path(raw).resolve()
|
|
136
|
+
except Exception:
|
|
137
|
+
continue
|
|
138
|
+
if cwd_path not in p.parents and p != cwd_path:
|
|
139
|
+
return "Error: Command blocked by safety guard (path outside working dir)"
|
|
140
|
+
|
|
141
|
+
return None
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Spawn tool for creating background subagents."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from kyber.agent.tools.base import Tool
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from kyber.agent.subagent import SubagentManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SpawnTool(Tool):
|
|
12
|
+
"""
|
|
13
|
+
Tool to spawn a subagent for background task execution.
|
|
14
|
+
|
|
15
|
+
The subagent runs asynchronously and announces its result back
|
|
16
|
+
to the main agent when complete.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, manager: "SubagentManager"):
|
|
20
|
+
self._manager = manager
|
|
21
|
+
self._origin_channel = "cli"
|
|
22
|
+
self._origin_chat_id = "direct"
|
|
23
|
+
|
|
24
|
+
def set_context(self, channel: str, chat_id: str) -> None:
|
|
25
|
+
"""Set the origin context for subagent announcements."""
|
|
26
|
+
self._origin_channel = channel
|
|
27
|
+
self._origin_chat_id = chat_id
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def name(self) -> str:
|
|
31
|
+
return "spawn"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def description(self) -> str:
|
|
35
|
+
return (
|
|
36
|
+
"Spawn a subagent to handle a task in the background. "
|
|
37
|
+
"Use this for complex or time-consuming tasks that can run independently. "
|
|
38
|
+
"The subagent will complete the task and report back when done."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def parameters(self) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"task": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"description": "The task for the subagent to complete",
|
|
49
|
+
},
|
|
50
|
+
"label": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "Optional short label for the task (for display)",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"required": ["task"],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
|
|
59
|
+
"""Spawn a subagent to execute the given task."""
|
|
60
|
+
return await self._manager.spawn(
|
|
61
|
+
task=task,
|
|
62
|
+
label=label,
|
|
63
|
+
origin_channel=self._origin_channel,
|
|
64
|
+
origin_chat_id=self._origin_chat_id,
|
|
65
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Task status tool for checking subagent progress."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from kyber.agent.tools.base import Tool
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from kyber.agent.subagent import SubagentManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TaskStatusTool(Tool):
|
|
12
|
+
"""
|
|
13
|
+
Instant-lookup tool for checking the status of background subagent tasks.
|
|
14
|
+
|
|
15
|
+
This is a lightweight tool that reads from an in-memory tracker — no LLM
|
|
16
|
+
calls or heavy work involved. The main agent should use this whenever the
|
|
17
|
+
user asks about the progress of a running task.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, manager: "SubagentManager"):
|
|
21
|
+
self._manager = manager
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def name(self) -> str:
|
|
25
|
+
return "task_status"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def description(self) -> str:
|
|
29
|
+
return (
|
|
30
|
+
"Check the status and progress of background subagent tasks. "
|
|
31
|
+
"Returns live progress including current step, elapsed time, and "
|
|
32
|
+
"recent actions. Use this when the user asks about the status of "
|
|
33
|
+
"a running task. Call with no arguments to see all tasks, or pass "
|
|
34
|
+
"a task_id to check a specific one."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def parameters(self) -> dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {
|
|
42
|
+
"task_id": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "Optional: specific task ID to check. Omit to see all tasks.",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"required": [],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async def execute(self, task_id: str | None = None, **kwargs: Any) -> str:
|
|
51
|
+
if task_id:
|
|
52
|
+
return self._manager.get_task_status(task_id)
|
|
53
|
+
return self._manager.get_all_status()
|
kyber/agent/tools/web.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Web tools: web_search and web_fetch."""
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from kyber.agent.tools.base import Tool
|
|
13
|
+
|
|
14
|
+
# Shared constants
|
|
15
|
+
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36"
|
|
16
|
+
MAX_REDIRECTS = 5 # Limit redirects to prevent DoS attacks
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _strip_tags(text: str) -> str:
|
|
20
|
+
"""Remove HTML tags and decode entities."""
|
|
21
|
+
text = re.sub(r'<script[\s\S]*?</script>', '', text, flags=re.I)
|
|
22
|
+
text = re.sub(r'<style[\s\S]*?</style>', '', text, flags=re.I)
|
|
23
|
+
text = re.sub(r'<[^>]+>', '', text)
|
|
24
|
+
return html.unescape(text).strip()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _normalize(text: str) -> str:
|
|
28
|
+
"""Normalize whitespace."""
|
|
29
|
+
text = re.sub(r'[ \t]+', ' ', text)
|
|
30
|
+
return re.sub(r'\n{3,}', '\n\n', text).strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _validate_url(url: str) -> tuple[bool, str]:
|
|
34
|
+
"""Validate URL: must be http(s) with valid domain."""
|
|
35
|
+
try:
|
|
36
|
+
p = urlparse(url)
|
|
37
|
+
if p.scheme not in ('http', 'https'):
|
|
38
|
+
return False, f"Only http/https allowed, got '{p.scheme or 'none'}'"
|
|
39
|
+
if not p.netloc:
|
|
40
|
+
return False, "Missing domain"
|
|
41
|
+
return True, ""
|
|
42
|
+
except Exception as e:
|
|
43
|
+
return False, str(e)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WebSearchTool(Tool):
|
|
47
|
+
"""Search the web using Brave Search API."""
|
|
48
|
+
|
|
49
|
+
name = "web_search"
|
|
50
|
+
description = "Search the web. Returns titles, URLs, and snippets."
|
|
51
|
+
parameters = {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"query": {"type": "string", "description": "Search query"},
|
|
55
|
+
"count": {"type": "integer", "description": "Results (1-10)", "minimum": 1, "maximum": 10}
|
|
56
|
+
},
|
|
57
|
+
"required": ["query"]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def __init__(self, api_key: str | None = None, max_results: int = 5):
|
|
61
|
+
self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "")
|
|
62
|
+
self.max_results = max_results
|
|
63
|
+
|
|
64
|
+
async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str:
|
|
65
|
+
if not self.api_key:
|
|
66
|
+
return "Error: BRAVE_API_KEY not configured"
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
n = min(max(count or self.max_results, 1), 10)
|
|
70
|
+
async with httpx.AsyncClient() as client:
|
|
71
|
+
r = await client.get(
|
|
72
|
+
"https://api.search.brave.com/res/v1/web/search",
|
|
73
|
+
params={"q": query, "count": n},
|
|
74
|
+
headers={"Accept": "application/json", "X-Subscription-Token": self.api_key},
|
|
75
|
+
timeout=10.0
|
|
76
|
+
)
|
|
77
|
+
r.raise_for_status()
|
|
78
|
+
|
|
79
|
+
results = r.json().get("web", {}).get("results", [])
|
|
80
|
+
if not results:
|
|
81
|
+
return f"No results for: {query}"
|
|
82
|
+
|
|
83
|
+
lines = [f"Results for: {query}\n"]
|
|
84
|
+
for i, item in enumerate(results[:n], 1):
|
|
85
|
+
lines.append(f"{i}. {item.get('title', '')}\n {item.get('url', '')}")
|
|
86
|
+
if desc := item.get("description"):
|
|
87
|
+
lines.append(f" {desc}")
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
return f"Error: {e}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class WebFetchTool(Tool):
|
|
94
|
+
"""Fetch and extract content from a URL using Readability."""
|
|
95
|
+
|
|
96
|
+
name = "web_fetch"
|
|
97
|
+
description = "Fetch URL and extract readable content (HTML → markdown/text)."
|
|
98
|
+
parameters = {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"properties": {
|
|
101
|
+
"url": {"type": "string", "description": "URL to fetch"},
|
|
102
|
+
"extractMode": {"type": "string", "enum": ["markdown", "text"], "default": "markdown"},
|
|
103
|
+
"maxChars": {"type": "integer", "minimum": 100}
|
|
104
|
+
},
|
|
105
|
+
"required": ["url"]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
def __init__(self, max_chars: int = 50000):
|
|
109
|
+
self.max_chars = max_chars
|
|
110
|
+
|
|
111
|
+
async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> str:
|
|
112
|
+
from readability import Document
|
|
113
|
+
|
|
114
|
+
max_chars = maxChars or self.max_chars
|
|
115
|
+
|
|
116
|
+
# Validate URL before fetching
|
|
117
|
+
is_valid, error_msg = _validate_url(url)
|
|
118
|
+
if not is_valid:
|
|
119
|
+
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url})
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
async with httpx.AsyncClient(
|
|
123
|
+
follow_redirects=True,
|
|
124
|
+
max_redirects=MAX_REDIRECTS,
|
|
125
|
+
timeout=30.0
|
|
126
|
+
) as client:
|
|
127
|
+
r = await client.get(url, headers={"User-Agent": USER_AGENT})
|
|
128
|
+
r.raise_for_status()
|
|
129
|
+
|
|
130
|
+
ctype = r.headers.get("content-type", "")
|
|
131
|
+
|
|
132
|
+
# JSON
|
|
133
|
+
if "application/json" in ctype:
|
|
134
|
+
text, extractor = json.dumps(r.json(), indent=2), "json"
|
|
135
|
+
# HTML
|
|
136
|
+
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
|
|
137
|
+
doc = Document(r.text)
|
|
138
|
+
content = self._to_markdown(doc.summary()) if extractMode == "markdown" else _strip_tags(doc.summary())
|
|
139
|
+
text = f"# {doc.title()}\n\n{content}" if doc.title() else content
|
|
140
|
+
extractor = "readability"
|
|
141
|
+
else:
|
|
142
|
+
text, extractor = r.text, "raw"
|
|
143
|
+
|
|
144
|
+
truncated = len(text) > max_chars
|
|
145
|
+
if truncated:
|
|
146
|
+
text = text[:max_chars]
|
|
147
|
+
|
|
148
|
+
return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code,
|
|
149
|
+
"extractor": extractor, "truncated": truncated, "length": len(text), "text": text})
|
|
150
|
+
except Exception as e:
|
|
151
|
+
return json.dumps({"error": str(e), "url": url})
|
|
152
|
+
|
|
153
|
+
def _to_markdown(self, html: str) -> str:
|
|
154
|
+
"""Convert HTML to markdown."""
|
|
155
|
+
# Convert links, headings, lists before stripping tags
|
|
156
|
+
text = re.sub(r'<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>([\s\S]*?)</a>',
|
|
157
|
+
lambda m: f'[{_strip_tags(m[2])}]({m[1]})', html, flags=re.I)
|
|
158
|
+
text = re.sub(r'<h([1-6])[^>]*>([\s\S]*?)</h\1>',
|
|
159
|
+
lambda m: f'\n{"#" * int(m[1])} {_strip_tags(m[2])}\n', text, flags=re.I)
|
|
160
|
+
text = re.sub(r'<li[^>]*>([\s\S]*?)</li>', lambda m: f'\n- {_strip_tags(m[1])}', text, flags=re.I)
|
|
161
|
+
text = re.sub(r'</(p|div|section|article)>', '\n\n', text, flags=re.I)
|
|
162
|
+
text = re.sub(r'<(br|hr)\s*/?>', '\n', text, flags=re.I)
|
|
163
|
+
return _normalize(_strip_tags(text))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kyber-whatsapp-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "WhatsApp bridge for kyber using Baileys",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"start": "node dist/index.js",
|
|
10
|
+
"dev": "tsc && node dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
|
14
|
+
"ws": "^8.17.0",
|
|
15
|
+
"qrcode-terminal": "^0.12.0",
|
|
16
|
+
"pino": "^9.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^20.14.0",
|
|
20
|
+
"@types/ws": "^8.5.10",
|
|
21
|
+
"typescript": "^5.4.0"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* kyber WhatsApp Bridge
|
|
4
|
+
*
|
|
5
|
+
* This bridge connects WhatsApp Web to kyber's Python backend
|
|
6
|
+
* via WebSocket. It handles authentication, message forwarding,
|
|
7
|
+
* and reconnection logic.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* npm run build && npm start
|
|
11
|
+
*
|
|
12
|
+
* Or with custom settings:
|
|
13
|
+
* BRIDGE_PORT=3001 AUTH_DIR=~/.kyber/whatsapp npm start
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Polyfill crypto for Baileys in ESM
|
|
17
|
+
import { webcrypto } from 'crypto';
|
|
18
|
+
if (!globalThis.crypto) {
|
|
19
|
+
(globalThis as any).crypto = webcrypto;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
import { BridgeServer } from './server.js';
|
|
23
|
+
import { homedir } from 'os';
|
|
24
|
+
import { join } from 'path';
|
|
25
|
+
|
|
26
|
+
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
|
|
27
|
+
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.kyber', 'whatsapp-auth');
|
|
28
|
+
|
|
29
|
+
console.log('💎 kyber WhatsApp Bridge');
|
|
30
|
+
console.log('========================\n');
|
|
31
|
+
|
|
32
|
+
const server = new BridgeServer(PORT, AUTH_DIR);
|
|
33
|
+
|
|
34
|
+
// Handle graceful shutdown
|
|
35
|
+
process.on('SIGINT', async () => {
|
|
36
|
+
console.log('\n\nShutting down...');
|
|
37
|
+
await server.stop();
|
|
38
|
+
process.exit(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
process.on('SIGTERM', async () => {
|
|
42
|
+
await server.stop();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Start the server
|
|
47
|
+
server.start().catch((error) => {
|
|
48
|
+
console.error('Failed to start bridge:', error);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
});
|