openab 0.3.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.
- openab/__init__.py +2 -0
- openab/__main__.py +5 -0
- openab/agents/__init__.py +71 -0
- openab/agents/claude.py +100 -0
- openab/agents/codex.py +64 -0
- openab/agents/cursor.py +62 -0
- openab/agents/gemini.py +53 -0
- openab/agents/openclaw.py +77 -0
- openab/chats/__init__.py +1 -0
- openab/chats/discord/__init__.py +3 -0
- openab/chats/discord/bot.py +167 -0
- openab/chats/telegram/__init__.py +3 -0
- openab/chats/telegram/bot.py +177 -0
- openab/cli/__init__.py +3 -0
- openab/cli/main.py +168 -0
- openab/core/__init__.py +3 -0
- openab/core/config.py +152 -0
- openab/core/i18n.py +138 -0
- openab-0.3.0.dist-info/METADATA +70 -0
- openab-0.3.0.dist-info/RECORD +24 -0
- openab-0.3.0.dist-info/WHEEL +5 -0
- openab-0.3.0.dist-info/entry_points.txt +2 -0
- openab-0.3.0.dist-info/licenses/LICENSE +21 -0
- openab-0.3.0.dist-info/top_level.txt +1 -0
openab/__init__.py
ADDED
openab/__main__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Agent backends: Cursor, Codex, Gemini, Claude, OpenClaw. 由 agent_config 或环境变量指定后端与选项。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from openab.core.i18n import t
|
|
10
|
+
|
|
11
|
+
from . import claude, codex, cursor, gemini, openclaw
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_backend(agent_config: dict[str, Any] | None = None) -> str:
|
|
15
|
+
if agent_config:
|
|
16
|
+
agent = agent_config.get("agent") or {}
|
|
17
|
+
b = agent.get("backend") or "cursor"
|
|
18
|
+
return str(b).strip().lower()
|
|
19
|
+
return (os.environ.get("OPENAB_AGENT") or "cursor").strip().lower()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_agent(
|
|
23
|
+
prompt: str,
|
|
24
|
+
*,
|
|
25
|
+
workspace: Optional[Path] = None,
|
|
26
|
+
timeout: Optional[int] = None,
|
|
27
|
+
agent_config: Optional[dict[str, Any]] = None,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""同步执行当前配置的 agent,返回完整 stdout 文本。"""
|
|
30
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
31
|
+
run_agent_async(
|
|
32
|
+
prompt,
|
|
33
|
+
workspace=workspace,
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
agent_config=agent_config,
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def run_agent_async(
|
|
41
|
+
prompt: str,
|
|
42
|
+
*,
|
|
43
|
+
workspace: Optional[Path] = None,
|
|
44
|
+
timeout: int = 300,
|
|
45
|
+
lang: str = "en",
|
|
46
|
+
agent_config: Optional[dict[str, Any]] = None,
|
|
47
|
+
) -> str:
|
|
48
|
+
"""异步执行 agent;backend 与各后端选项来自 agent_config,缺省时回退到环境变量。"""
|
|
49
|
+
backend = get_backend(agent_config)
|
|
50
|
+
if backend == "codex":
|
|
51
|
+
return await codex.run_async(
|
|
52
|
+
prompt, workspace=workspace, timeout=timeout, lang=lang, agent_config=agent_config
|
|
53
|
+
)
|
|
54
|
+
if backend == "gemini":
|
|
55
|
+
return await gemini.run_async(
|
|
56
|
+
prompt, workspace=workspace, timeout=timeout, lang=lang, agent_config=agent_config
|
|
57
|
+
)
|
|
58
|
+
if backend == "claude":
|
|
59
|
+
return await claude.run_async(
|
|
60
|
+
prompt, workspace=workspace, timeout=timeout, lang=lang, agent_config=agent_config
|
|
61
|
+
)
|
|
62
|
+
if backend == "openclaw":
|
|
63
|
+
return await openclaw.run_async(
|
|
64
|
+
prompt, workspace=workspace, timeout=timeout, lang=lang, agent_config=agent_config
|
|
65
|
+
)
|
|
66
|
+
return await cursor.run_async(
|
|
67
|
+
prompt, workspace=workspace, timeout=timeout, lang=lang, agent_config=agent_config
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
__all__ = ["run_agent", "run_agent_async", "get_backend"]
|
openab/agents/claude.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Claude Code CLI backend (https://code.claude.com/docs/en/cli-reference).
|
|
2
|
+
|
|
3
|
+
Print mode: claude -p "query" — response to stdout, then exit.
|
|
4
|
+
Flags used: --output-format text, --no-session-persistence; optional: --model, --max-turns, --add-dir.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from openab.core.i18n import t
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _find_cmd(agent_config: dict[str, Any] | None = None) -> str:
|
|
18
|
+
cmd = "claude"
|
|
19
|
+
if agent_config:
|
|
20
|
+
c = (agent_config.get("claude") or {}).get("cmd")
|
|
21
|
+
if c:
|
|
22
|
+
cmd = str(c)
|
|
23
|
+
if not cmd:
|
|
24
|
+
cmd = os.environ.get("CLAUDE_CLI_CMD", "claude")
|
|
25
|
+
if os.path.isabs(cmd):
|
|
26
|
+
return cmd
|
|
27
|
+
exe = shutil.which(cmd)
|
|
28
|
+
return exe or cmd
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_args(
|
|
32
|
+
prompt: str,
|
|
33
|
+
workspace: Optional[Path],
|
|
34
|
+
agent_config: dict[str, Any] | None = None,
|
|
35
|
+
) -> list[str]:
|
|
36
|
+
cmd = _find_cmd(agent_config)
|
|
37
|
+
args = [
|
|
38
|
+
cmd,
|
|
39
|
+
"--print",
|
|
40
|
+
"--output-format", "text",
|
|
41
|
+
"--no-session-persistence",
|
|
42
|
+
]
|
|
43
|
+
model = ""
|
|
44
|
+
max_turns = ""
|
|
45
|
+
add_dirs: list[str] = []
|
|
46
|
+
if agent_config:
|
|
47
|
+
c = agent_config.get("claude") or {}
|
|
48
|
+
if c.get("model"):
|
|
49
|
+
model = str(c["model"]).strip()
|
|
50
|
+
if c.get("max_turns") is not None:
|
|
51
|
+
max_turns = str(c["max_turns"]).strip()
|
|
52
|
+
ad = c.get("add_dir")
|
|
53
|
+
if isinstance(ad, str) and ad:
|
|
54
|
+
add_dirs = [d.strip() for d in ad.split(os.pathsep) if d.strip()]
|
|
55
|
+
elif isinstance(ad, list):
|
|
56
|
+
add_dirs = [str(x).strip() for x in ad if str(x).strip()]
|
|
57
|
+
if not model:
|
|
58
|
+
model = os.environ.get("CLAUDE_CLI_MODEL", "").strip()
|
|
59
|
+
if not max_turns:
|
|
60
|
+
max_turns = os.environ.get("CLAUDE_CLI_MAX_TURNS", "").strip()
|
|
61
|
+
if not add_dirs:
|
|
62
|
+
raw = os.environ.get("CLAUDE_CLI_ADD_DIR", "").strip()
|
|
63
|
+
if raw:
|
|
64
|
+
add_dirs = [d.strip() for d in raw.split(os.pathsep) if d.strip()]
|
|
65
|
+
if model:
|
|
66
|
+
args.extend(["--model", model])
|
|
67
|
+
if max_turns and max_turns.isdigit():
|
|
68
|
+
args.extend(["--max-turns", max_turns])
|
|
69
|
+
for d in add_dirs:
|
|
70
|
+
args.extend(["--add-dir", d])
|
|
71
|
+
args.append(prompt)
|
|
72
|
+
return args
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def run_async(
|
|
76
|
+
prompt: str,
|
|
77
|
+
*,
|
|
78
|
+
workspace: Optional[Path] = None,
|
|
79
|
+
timeout: int = 300,
|
|
80
|
+
lang: str = "en",
|
|
81
|
+
agent_config: Optional[dict[str, Any]] = None,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Run Claude Code CLI in print mode; return stdout as reply."""
|
|
84
|
+
args = _build_args(prompt, workspace, agent_config)
|
|
85
|
+
cwd = str(workspace) if workspace else None
|
|
86
|
+
proc = await asyncio.create_subprocess_exec(
|
|
87
|
+
*args,
|
|
88
|
+
stdout=asyncio.subprocess.PIPE,
|
|
89
|
+
stderr=asyncio.subprocess.PIPE,
|
|
90
|
+
env=os.environ.copy(),
|
|
91
|
+
cwd=cwd,
|
|
92
|
+
)
|
|
93
|
+
try:
|
|
94
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
95
|
+
except asyncio.TimeoutError:
|
|
96
|
+
proc.kill()
|
|
97
|
+
await proc.wait()
|
|
98
|
+
return t(lang, "agent_timeout")
|
|
99
|
+
text = (stdout or b"").decode("utf-8", errors="replace").strip()
|
|
100
|
+
return text or t(lang, "agent_no_output")
|
openab/agents/codex.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""OpenAI Codex CLI backend."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from openab.core.i18n import t
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _find_cmd(agent_config: dict[str, Any] | None = None) -> str:
|
|
14
|
+
cmd = "codex"
|
|
15
|
+
if agent_config:
|
|
16
|
+
c = (agent_config.get("codex") or {}).get("cmd")
|
|
17
|
+
if c:
|
|
18
|
+
cmd = str(c)
|
|
19
|
+
if not cmd:
|
|
20
|
+
cmd = os.environ.get("CODEX_CMD", "codex")
|
|
21
|
+
if os.path.isabs(cmd):
|
|
22
|
+
return cmd
|
|
23
|
+
exe = shutil.which(cmd)
|
|
24
|
+
return exe or cmd
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _skip_git_check(agent_config: dict[str, Any] | None = None) -> bool:
|
|
28
|
+
if agent_config:
|
|
29
|
+
v = (agent_config.get("codex") or {}).get("skip_git_check")
|
|
30
|
+
if v in (True, "true", "1", 1):
|
|
31
|
+
return True
|
|
32
|
+
return os.environ.get("CODEX_SKIP_GIT_CHECK", "").strip().lower() in ("1", "true", "yes")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def run_async(
|
|
36
|
+
prompt: str,
|
|
37
|
+
*,
|
|
38
|
+
workspace: Optional[Path] = None,
|
|
39
|
+
timeout: int = 300,
|
|
40
|
+
lang: str = "en",
|
|
41
|
+
agent_config: Optional[dict[str, Any]] = None,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Codex CLI: codex exec --ephemeral, final message on stdout."""
|
|
44
|
+
cmd = _find_cmd(agent_config)
|
|
45
|
+
args = [cmd, "exec", "--ephemeral"]
|
|
46
|
+
if _skip_git_check(agent_config):
|
|
47
|
+
args.append("--skip-git-repo-check")
|
|
48
|
+
args.append(prompt)
|
|
49
|
+
cwd = str(workspace) if workspace else None
|
|
50
|
+
proc = await asyncio.create_subprocess_exec(
|
|
51
|
+
*args,
|
|
52
|
+
stdout=asyncio.subprocess.PIPE,
|
|
53
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
54
|
+
env=os.environ.copy(),
|
|
55
|
+
cwd=cwd,
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
59
|
+
except asyncio.TimeoutError:
|
|
60
|
+
proc.kill()
|
|
61
|
+
await proc.wait()
|
|
62
|
+
return t(lang, "agent_timeout")
|
|
63
|
+
text = (stdout or b"").decode("utf-8", errors="replace").strip()
|
|
64
|
+
return text or t(lang, "agent_no_output")
|
openab/agents/cursor.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Cursor Agent CLI backend."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from openab.core.i18n import t
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _find_cmd(agent_config: dict[str, Any] | None = None) -> str:
|
|
14
|
+
cmd = "agent"
|
|
15
|
+
if agent_config:
|
|
16
|
+
c = (agent_config.get("cursor") or {}).get("cmd")
|
|
17
|
+
if c:
|
|
18
|
+
cmd = str(c)
|
|
19
|
+
if not cmd:
|
|
20
|
+
cmd = os.environ.get("CURSOR_AGENT_CMD", "agent")
|
|
21
|
+
if os.path.isabs(cmd):
|
|
22
|
+
return cmd
|
|
23
|
+
exe = shutil.which(cmd)
|
|
24
|
+
return exe or cmd
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def run_async(
|
|
28
|
+
prompt: str,
|
|
29
|
+
*,
|
|
30
|
+
workspace: Optional[Path] = None,
|
|
31
|
+
timeout: int = 300,
|
|
32
|
+
lang: str = "en",
|
|
33
|
+
agent_config: Optional[dict[str, Any]] = None,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Cursor Agent CLI: agent --print --trust."""
|
|
36
|
+
cmd = _find_cmd(agent_config)
|
|
37
|
+
base_args = [
|
|
38
|
+
cmd,
|
|
39
|
+
"agent",
|
|
40
|
+
"--print",
|
|
41
|
+
"--output-format", "text",
|
|
42
|
+
"--trust",
|
|
43
|
+
]
|
|
44
|
+
if workspace is not None:
|
|
45
|
+
base_args.extend(["--workspace", str(workspace)])
|
|
46
|
+
base_args.extend(["--", prompt])
|
|
47
|
+
env = os.environ.copy()
|
|
48
|
+
proc = await asyncio.create_subprocess_exec(
|
|
49
|
+
*base_args,
|
|
50
|
+
stdout=asyncio.subprocess.PIPE,
|
|
51
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
52
|
+
env=env,
|
|
53
|
+
cwd=str(workspace) if workspace else None,
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
57
|
+
except asyncio.TimeoutError:
|
|
58
|
+
proc.kill()
|
|
59
|
+
await proc.wait()
|
|
60
|
+
return t(lang, "agent_timeout")
|
|
61
|
+
text = (stdout or b"").decode("utf-8", errors="replace").strip()
|
|
62
|
+
return text or t(lang, "agent_no_output")
|
openab/agents/gemini.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Gemini CLI backend (google-gemini/gemini-cli)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from openab.core.i18n import t
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _find_cmd(agent_config: dict[str, Any] | None = None) -> str:
|
|
14
|
+
cmd = "gemini"
|
|
15
|
+
if agent_config:
|
|
16
|
+
c = (agent_config.get("gemini") or {}).get("cmd")
|
|
17
|
+
if c:
|
|
18
|
+
cmd = str(c)
|
|
19
|
+
if not cmd:
|
|
20
|
+
cmd = os.environ.get("GEMINI_CLI_CMD", "gemini")
|
|
21
|
+
if os.path.isabs(cmd):
|
|
22
|
+
return cmd
|
|
23
|
+
exe = shutil.which(cmd)
|
|
24
|
+
return exe or cmd
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def run_async(
|
|
28
|
+
prompt: str,
|
|
29
|
+
*,
|
|
30
|
+
workspace: Optional[Path] = None,
|
|
31
|
+
timeout: int = 300,
|
|
32
|
+
lang: str = "en",
|
|
33
|
+
agent_config: Optional[dict[str, Any]] = None,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Gemini CLI: gemini -p \"prompt\" → stdout."""
|
|
36
|
+
cmd = _find_cmd(agent_config)
|
|
37
|
+
args = [cmd, "-p", prompt]
|
|
38
|
+
cwd = str(workspace) if workspace else None
|
|
39
|
+
proc = await asyncio.create_subprocess_exec(
|
|
40
|
+
*args,
|
|
41
|
+
stdout=asyncio.subprocess.PIPE,
|
|
42
|
+
stderr=asyncio.subprocess.PIPE,
|
|
43
|
+
env=os.environ.copy(),
|
|
44
|
+
cwd=cwd,
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
48
|
+
except asyncio.TimeoutError:
|
|
49
|
+
proc.kill()
|
|
50
|
+
await proc.wait()
|
|
51
|
+
return t(lang, "agent_timeout")
|
|
52
|
+
text = (stdout or b"").decode("utf-8", errors="replace").strip()
|
|
53
|
+
return text or t(lang, "agent_no_output")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""OpenClaw 后端:通过 CLI `openclaw agent --message "..."` 调用,需先运行 OpenClaw Gateway(或使用本地回退)。
|
|
2
|
+
|
|
3
|
+
参考:https://docs.openclaw.ai/tools/agent-send
|
|
4
|
+
安装:npm install -g openclaw@latest,并运行 openclaw onboard / openclaw gateway。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from openab.core.i18n import t
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _find_cmd(agent_config: dict[str, Any] | None = None) -> str:
|
|
18
|
+
cmd = "openclaw"
|
|
19
|
+
if agent_config:
|
|
20
|
+
c = (agent_config.get("openclaw") or {}).get("cmd")
|
|
21
|
+
if c:
|
|
22
|
+
cmd = str(c)
|
|
23
|
+
if not cmd:
|
|
24
|
+
cmd = os.environ.get("OPENCLAW_CMD", "openclaw")
|
|
25
|
+
if os.path.isabs(cmd):
|
|
26
|
+
return cmd
|
|
27
|
+
exe = shutil.which(cmd)
|
|
28
|
+
return exe or cmd
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _strip_media_lines(text: str) -> str:
|
|
32
|
+
"""去掉 OpenClaw 输出中的 MEDIA: 行,只保留回复正文。"""
|
|
33
|
+
lines = []
|
|
34
|
+
for line in text.splitlines():
|
|
35
|
+
if line.strip().startswith("MEDIA:"):
|
|
36
|
+
continue
|
|
37
|
+
lines.append(line)
|
|
38
|
+
return "\n".join(lines).strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def run_async(
|
|
42
|
+
prompt: str,
|
|
43
|
+
*,
|
|
44
|
+
workspace: Optional[Path] = None,
|
|
45
|
+
timeout: int = 300,
|
|
46
|
+
lang: str = "en",
|
|
47
|
+
agent_config: Optional[dict[str, Any]] = None,
|
|
48
|
+
) -> str:
|
|
49
|
+
"""调用 openclaw agent --message \"<prompt>\",从 stdout 取回复;默认会过滤 MEDIA: 行。"""
|
|
50
|
+
cmd = _find_cmd(agent_config)
|
|
51
|
+
args = [cmd, "agent", "--message", prompt]
|
|
52
|
+
oc_cfg = (agent_config or {}).get("openclaw") or {}
|
|
53
|
+
to = oc_cfg.get("timeout") or timeout
|
|
54
|
+
if to and int(to) > 0:
|
|
55
|
+
args.extend(["--timeout", str(int(to))])
|
|
56
|
+
thinking = (oc_cfg.get("thinking") or "").strip().lower()
|
|
57
|
+
if thinking in ("off", "minimal", "low", "medium", "high", "xhigh"):
|
|
58
|
+
args.extend(["--thinking", thinking])
|
|
59
|
+
cwd = str(workspace) if workspace else None
|
|
60
|
+
proc = await asyncio.create_subprocess_exec(
|
|
61
|
+
*args,
|
|
62
|
+
stdout=asyncio.subprocess.PIPE,
|
|
63
|
+
stderr=asyncio.subprocess.PIPE,
|
|
64
|
+
env=os.environ.copy(),
|
|
65
|
+
cwd=cwd,
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
69
|
+
except asyncio.TimeoutError:
|
|
70
|
+
proc.kill()
|
|
71
|
+
await proc.wait()
|
|
72
|
+
return t(lang, "agent_timeout")
|
|
73
|
+
text = (stdout or b"").decode("utf-8", errors="replace")
|
|
74
|
+
text = _strip_media_lines(text)
|
|
75
|
+
if not text.strip():
|
|
76
|
+
return t(lang, "agent_no_output")
|
|
77
|
+
return text
|
openab/chats/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Chat frontends: Telegram, (future: Discord, Slack, …)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Discord bot: receive messages → call agent → reply (chunked). 配置通过参数传入,不读环境变量。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import discord
|
|
10
|
+
from discord import Intents
|
|
11
|
+
|
|
12
|
+
from openab.agents import run_agent_async
|
|
13
|
+
from openab.core.i18n import t
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
MAX_MESSAGE_LENGTH = 2000
|
|
18
|
+
PREFIX = "!"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _split_message(text: str, max_len: int = MAX_MESSAGE_LENGTH) -> list[str]:
|
|
22
|
+
chunks = []
|
|
23
|
+
while text:
|
|
24
|
+
if len(text) <= max_len:
|
|
25
|
+
chunks.append(text)
|
|
26
|
+
break
|
|
27
|
+
cut = text.rfind("\n", 0, max_len + 1)
|
|
28
|
+
if cut <= 0:
|
|
29
|
+
cut = text.rfind(" ", 0, max_len + 1)
|
|
30
|
+
if cut <= 0:
|
|
31
|
+
cut = max_len
|
|
32
|
+
chunks.append(text[:cut].rstrip())
|
|
33
|
+
text = text[cut:].lstrip()
|
|
34
|
+
return chunks
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _user_lang(_message: discord.Message) -> str:
|
|
38
|
+
return "en"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _typing_until_done(channel: discord.abc.Messageable, done: asyncio.Event) -> None:
|
|
42
|
+
while not done.is_set():
|
|
43
|
+
try:
|
|
44
|
+
async with channel.typing():
|
|
45
|
+
await asyncio.wait_for(done.wait(), timeout=4.0)
|
|
46
|
+
except (asyncio.TimeoutError, discord.DiscordException):
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OpenABDiscordBot(discord.Client):
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
intents: Intents,
|
|
55
|
+
allowed_user_ids: frozenset[int],
|
|
56
|
+
workspace: Path,
|
|
57
|
+
timeout: int = 300,
|
|
58
|
+
agent_config: Optional[dict[str, Any]] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(intents=intents)
|
|
61
|
+
self._openab_allowed = allowed_user_ids
|
|
62
|
+
self._openab_workspace = workspace
|
|
63
|
+
self._openab_timeout = timeout
|
|
64
|
+
self._openab_agent_config = agent_config or {}
|
|
65
|
+
|
|
66
|
+
def _is_user_allowed(self, user_id: int) -> bool:
|
|
67
|
+
return user_id in self._openab_allowed
|
|
68
|
+
|
|
69
|
+
def _is_auth_enabled(self) -> bool:
|
|
70
|
+
return len(self._openab_allowed) > 0
|
|
71
|
+
|
|
72
|
+
async def handle_command_start(self, message: discord.Message) -> None:
|
|
73
|
+
lang = _user_lang(message)
|
|
74
|
+
user_id = message.author.id
|
|
75
|
+
if self._is_user_allowed(user_id):
|
|
76
|
+
msg = t(lang, "start_welcome")
|
|
77
|
+
else:
|
|
78
|
+
key = "auth_not_configured" if not self._is_auth_enabled() else "unauthorized"
|
|
79
|
+
msg = t(lang, key) + "\n\n" + t(lang, "your_user_id") + str(user_id)
|
|
80
|
+
await message.reply(msg)
|
|
81
|
+
|
|
82
|
+
async def handle_command_whoami(self, message: discord.Message) -> None:
|
|
83
|
+
lang = _user_lang(message)
|
|
84
|
+
user_id = message.author.id
|
|
85
|
+
name = message.author.display_name or str(message.author)
|
|
86
|
+
status = t(lang, "status_authorized") if self._is_user_allowed(user_id) else t(lang, "status_unauthorized")
|
|
87
|
+
msg = (
|
|
88
|
+
f"{t(lang, 'whoami_id')}{user_id}\n"
|
|
89
|
+
f"{t(lang, 'whoami_username')}{name}\n"
|
|
90
|
+
f"{t(lang, 'whoami_status')}{status}"
|
|
91
|
+
)
|
|
92
|
+
await message.reply(msg)
|
|
93
|
+
|
|
94
|
+
async def handle_agent_message(self, message: discord.Message) -> None:
|
|
95
|
+
user_id = message.author.id
|
|
96
|
+
lang = _user_lang(message)
|
|
97
|
+
if not self._is_user_allowed(user_id):
|
|
98
|
+
key = "auth_not_configured" if not self._is_auth_enabled() else "unauthorized"
|
|
99
|
+
msg = t(lang, key) + "\n\n" + t(lang, "your_user_id") + str(user_id)
|
|
100
|
+
await message.reply(msg)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
prompt = (message.content or "").strip()
|
|
104
|
+
if not prompt:
|
|
105
|
+
await message.reply(t(lang, "prompt_empty"))
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
done = asyncio.Event()
|
|
109
|
+
typing_task = asyncio.create_task(_typing_until_done(message.channel, done))
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
reply = await run_agent_async(
|
|
113
|
+
prompt,
|
|
114
|
+
workspace=self._openab_workspace,
|
|
115
|
+
timeout=self._openab_timeout,
|
|
116
|
+
lang=lang,
|
|
117
|
+
agent_config=self._openab_agent_config,
|
|
118
|
+
)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.exception("agent run error")
|
|
121
|
+
reply = t(lang, "agent_error", error=str(e))
|
|
122
|
+
finally:
|
|
123
|
+
done.set()
|
|
124
|
+
typing_task.cancel()
|
|
125
|
+
try:
|
|
126
|
+
await typing_task
|
|
127
|
+
except asyncio.CancelledError:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
for chunk in _split_message(reply):
|
|
131
|
+
await message.reply(chunk)
|
|
132
|
+
|
|
133
|
+
async def on_ready(self) -> None:
|
|
134
|
+
logger.info("Discord bot logged in as %s", self.user)
|
|
135
|
+
|
|
136
|
+
async def on_message(self, message: discord.Message) -> None:
|
|
137
|
+
if message.author.bot:
|
|
138
|
+
return
|
|
139
|
+
content = (message.content or "").strip()
|
|
140
|
+
if content == f"{PREFIX}start":
|
|
141
|
+
await self.handle_command_start(message)
|
|
142
|
+
return
|
|
143
|
+
if content == f"{PREFIX}whoami":
|
|
144
|
+
await self.handle_command_whoami(message)
|
|
145
|
+
return
|
|
146
|
+
await self.handle_agent_message(message)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run_bot(
|
|
150
|
+
token: str,
|
|
151
|
+
*,
|
|
152
|
+
workspace: Path,
|
|
153
|
+
timeout: int = 300,
|
|
154
|
+
allowed_user_ids: Optional[frozenset[int]] = None,
|
|
155
|
+
agent_config: Optional[dict[str, Any]] = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
ids = allowed_user_ids or frozenset()
|
|
158
|
+
intents = Intents.default()
|
|
159
|
+
intents.message_content = True
|
|
160
|
+
client = OpenABDiscordBot(
|
|
161
|
+
intents=intents,
|
|
162
|
+
allowed_user_ids=ids,
|
|
163
|
+
workspace=workspace,
|
|
164
|
+
timeout=timeout,
|
|
165
|
+
agent_config=agent_config,
|
|
166
|
+
)
|
|
167
|
+
client.run(token)
|