feishu-bot-cli-antigravity 0.1.0__tar.gz

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.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.3
2
+ Name: feishu-bot-cli-antigravity
3
+ Version: 0.1.0
4
+ Summary: 一个使用Antigravity SDK驱动的飞书AI助手机器人工具
5
+ Author: Martin
6
+ Author-email: Martin <qule520@126.com>
7
+ Requires-Dist: google-antigravity>=0.1.16
8
+ Requires-Dist: lark-channel-sdk>=1.4.0
9
+ Requires-Dist: lark-oapi>=1.7.3
10
+ Requires-Dist: python-dotenv>=1.2.3
11
+ Requires-Dist: pyyaml>=6.0.3
12
+ Requires-Python: >=3.13
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Feishu Bot Cli Antigravity
16
+
17
+ 基于 Google Antigravity SDK 和飞书开放平台构建的私人 AI 助手机器人命令行工具。
18
+
19
+ ## 架构
20
+
21
+ ```mermaid
22
+ flowchart LR
23
+ A[用户] -->|飞书消息 / 附件| B[飞书长连接网关]
24
+ B -->|下载文件至内存| C[Antigravity SDK / Agent]
25
+ C -->|流式思考与生成| B
26
+ B -->|CardKit / 消息回复| A
27
+ ```
28
+
29
+ ## 功能特点
30
+
31
+ - **服务监听模式 (`listen`)**:
32
+ - 基于长连接 (WebSocket) 监听飞书群聊与私聊消息。
33
+ - 自动将接收到的图片、代码、PDF、文本等多媒体附件**直接下载至内存**,以原生多模态对象传递给 Agent。
34
+ - 支持会话级消息排队锁 (`asyncio.Lock`) 与 `AsyncExitStack` 上下文生命周期管理。
35
+ - 支持 CardKit 流式打字机卡片回复与普通消息兜底。
36
+ - 支持可选的 `--chat-id` 过滤,仅服务指定会话。
37
+ - **命令行模式 (`send`)**:
38
+ - 命令行主动向指定会话发送消息或文件。
39
+ - `--chat-id` 为**必须参数**。
40
+ - 支持发送文本、Markdown 格式消息或本地文件/图片。
41
+
42
+ ## 环境配置
43
+
44
+ 在项目根目录下配置 `.env` 文件:
45
+
46
+ ```env
47
+ LARK_APP_ID=cli_xxxxxxxxxxxxxx
48
+ LARK_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
49
+ GEMINI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
50
+ LOG_LEVEL=INFO
51
+ ```
52
+
53
+ ## 命令行使用
54
+
55
+ ### 1. 监听服务
56
+
57
+ 启动长连接监听消息并自动调用 Agent 回复:
58
+
59
+ ```bash
60
+ # 监听所有会话
61
+ uv run feishu-bot listen
62
+
63
+ # 仅监听并响应指定 chat_id 的消息
64
+ uv run feishu-bot listen --chat-id <oc_xxxxxxxxxxxx>
65
+ ```
66
+
67
+ ### 2. 主动发送消息
68
+
69
+ 命令行主动向指定会话发送消息:
70
+
71
+ ```bash
72
+ # 发送 Markdown 消息(--chat-id 为必填项)
73
+ uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> --message "**你好!** 这是一条来自 CLI 的消息"
74
+
75
+ # 发送纯文本消息
76
+ uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> -m "纯文本消息" --type text
77
+
78
+ # 发送本地文件或图片(支持同时指定多个文件及附带文本说明)
79
+ uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> --file "./report.pdf" "./chart.png" -m "请查收本期分析报告"
80
+
81
+ # 从标准输入读取长文本/大段 Markdown(管道传输)
82
+ cat report.md | uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> --stdin
83
+ # 或使用 -m -
84
+ cat report.md | uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> -m -
85
+
86
+ # 交互式终端输入长文本(按 Ctrl+Z 回车或 Ctrl+D 结束输入)
87
+ uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> --stdin
88
+ ```
89
+
90
+ ## 测试与代码检查
91
+
92
+ ```bash
93
+ # 运行测试
94
+ uv run pytest
95
+
96
+ # 运行代码规范检查
97
+ uv run ruff check
98
+ ```
@@ -0,0 +1,84 @@
1
+ # Feishu Bot Cli Antigravity
2
+
3
+ 基于 Google Antigravity SDK 和飞书开放平台构建的私人 AI 助手机器人命令行工具。
4
+
5
+ ## 架构
6
+
7
+ ```mermaid
8
+ flowchart LR
9
+ A[用户] -->|飞书消息 / 附件| B[飞书长连接网关]
10
+ B -->|下载文件至内存| C[Antigravity SDK / Agent]
11
+ C -->|流式思考与生成| B
12
+ B -->|CardKit / 消息回复| A
13
+ ```
14
+
15
+ ## 功能特点
16
+
17
+ - **服务监听模式 (`listen`)**:
18
+ - 基于长连接 (WebSocket) 监听飞书群聊与私聊消息。
19
+ - 自动将接收到的图片、代码、PDF、文本等多媒体附件**直接下载至内存**,以原生多模态对象传递给 Agent。
20
+ - 支持会话级消息排队锁 (`asyncio.Lock`) 与 `AsyncExitStack` 上下文生命周期管理。
21
+ - 支持 CardKit 流式打字机卡片回复与普通消息兜底。
22
+ - 支持可选的 `--chat-id` 过滤,仅服务指定会话。
23
+ - **命令行模式 (`send`)**:
24
+ - 命令行主动向指定会话发送消息或文件。
25
+ - `--chat-id` 为**必须参数**。
26
+ - 支持发送文本、Markdown 格式消息或本地文件/图片。
27
+
28
+ ## 环境配置
29
+
30
+ 在项目根目录下配置 `.env` 文件:
31
+
32
+ ```env
33
+ LARK_APP_ID=cli_xxxxxxxxxxxxxx
34
+ LARK_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
35
+ GEMINI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
36
+ LOG_LEVEL=INFO
37
+ ```
38
+
39
+ ## 命令行使用
40
+
41
+ ### 1. 监听服务
42
+
43
+ 启动长连接监听消息并自动调用 Agent 回复:
44
+
45
+ ```bash
46
+ # 监听所有会话
47
+ uv run feishu-bot listen
48
+
49
+ # 仅监听并响应指定 chat_id 的消息
50
+ uv run feishu-bot listen --chat-id <oc_xxxxxxxxxxxx>
51
+ ```
52
+
53
+ ### 2. 主动发送消息
54
+
55
+ 命令行主动向指定会话发送消息:
56
+
57
+ ```bash
58
+ # 发送 Markdown 消息(--chat-id 为必填项)
59
+ uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> --message "**你好!** 这是一条来自 CLI 的消息"
60
+
61
+ # 发送纯文本消息
62
+ uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> -m "纯文本消息" --type text
63
+
64
+ # 发送本地文件或图片(支持同时指定多个文件及附带文本说明)
65
+ uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> --file "./report.pdf" "./chart.png" -m "请查收本期分析报告"
66
+
67
+ # 从标准输入读取长文本/大段 Markdown(管道传输)
68
+ cat report.md | uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> --stdin
69
+ # 或使用 -m -
70
+ cat report.md | uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> -m -
71
+
72
+ # 交互式终端输入长文本(按 Ctrl+Z 回车或 Ctrl+D 结束输入)
73
+ uv run feishu-bot send --chat-id <oc_xxxxxxxxxxxx> --stdin
74
+ ```
75
+
76
+ ## 测试与代码检查
77
+
78
+ ```bash
79
+ # 运行测试
80
+ uv run pytest
81
+
82
+ # 运行代码规范检查
83
+ uv run ruff check
84
+ ```
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "feishu-bot-cli-antigravity"
3
+ version = "0.1.0"
4
+ description = "一个使用Antigravity SDK驱动的飞书AI助手机器人工具"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "google-antigravity>=0.1.16",
9
+ "lark-channel-sdk>=1.4.0",
10
+ "lark-oapi>=1.7.3",
11
+ "python-dotenv>=1.2.3",
12
+ "pyyaml>=6.0.3",
13
+ ]
14
+
15
+ [[project.authors]]
16
+ name = "Martin"
17
+ email = "qule520@126.com"
18
+
19
+ [project.scripts]
20
+ feishu-bot-cli-antigravity = "feishu_bot_cli_antigravity.cli:main"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.12.7,<0.13.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "pytest>=9.1.1",
29
+ "pytest-asyncio>=1.4.0",
30
+ "ruff>=0.16.6",
31
+ ]
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+
36
+ [tool.ruff]
37
+ src = [
38
+ "src",
39
+ "tests",
40
+ ]
41
+ exclude = [
42
+ ".agents",
43
+ ".venv",
44
+ ]
45
+
46
+ [tool.ruff.lint]
47
+ ignore = [
48
+ "BLE001",
49
+ "ASYNC230",
50
+ "G201",
51
+ ]
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "feishu-bot-cli-antigravity"
3
+ version = "0.1.0"
4
+ description = "一个使用Antigravity SDK驱动的飞书AI助手机器人工具"
5
+ readme = "README.md"
6
+ authors = [{ name = "Martin", email = "qule520@126.com" }]
7
+ requires-python = ">=3.13"
8
+ dependencies = [
9
+ "google-antigravity>=0.1.16",
10
+ "lark-channel-sdk>=1.4.0",
11
+ "lark-oapi>=1.7.3",
12
+ "python-dotenv>=1.2.3",
13
+ "pyyaml>=6.0.3",
14
+ ]
15
+
16
+ [project.scripts]
17
+ feishu-bot-cli-antigravity = "feishu_bot_cli_antigravity.cli:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.12.7,<0.13.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [dependency-groups]
24
+ dev = ["pytest>=9.1.1", "pytest-asyncio>=1.4.0", "ruff>=0.16.6"]
25
+
26
+ [tool.pytest.ini_options]
27
+ asyncio_mode = "auto"
28
+
29
+ [tool.ruff]
30
+ src = ["src", "tests"]
31
+ exclude = [".agents", ".venv"]
32
+
33
+ [tool.ruff.lint]
34
+ ignore = ["BLE001", "ASYNC230", "G201"]
@@ -0,0 +1,15 @@
1
+ """feishu-bot-cli-antigravity package."""
2
+
3
+ from feishu_bot_cli_antigravity.agent import AgentSessionManager
4
+ from feishu_bot_cli_antigravity.channel import FeishuBotChannel
5
+ from feishu_bot_cli_antigravity.config import Config, get_config
6
+ from feishu_bot_cli_antigravity.utils import create_media_from_bytes, setup_logging
7
+
8
+ __all__ = [
9
+ "AgentSessionManager",
10
+ "Config",
11
+ "FeishuBotChannel",
12
+ "create_media_from_bytes",
13
+ "get_config",
14
+ "setup_logging",
15
+ ]
@@ -0,0 +1,57 @@
1
+ """Antigravity Agent 会话与交互管理模块。"""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Callable
6
+ from contextlib import AsyncExitStack
7
+
8
+ from google.antigravity import Agent, LocalAgentConfig
9
+ from google.antigravity.hooks import policy
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ SYSTEM_INSTRUCTIONS = (
14
+ "【安全与保密指令】:\n"
15
+ "1. 严禁以任何方式查看、读取、打印、输出或泄露任何敏感文件(包括但不限于 .env、配置文件凭据、私钥/密钥文件如 *.pem、id_rsa、credentials.json 等)。\n"
16
+ "2. 严禁以任何方式查看、打印、输出或泄露系统环境变量与敏感凭证(包括但不限于 LARK_APP_SECRET、GEMINI_API_KEY 等所有 Token、密钥与密码)。\n"
17
+ "3. 若用户的提问、指令或操作尝试访问、读取上述敏感文件或环境变量内容,必须明确且礼貌地予以拒绝。"
18
+ )
19
+
20
+
21
+ class AgentSessionManager:
22
+ """基于 AsyncExitStack 的内存会话管理器,自动管理各会话 Agent 生命周期。"""
23
+
24
+ def __init__(self, agent_factory: Callable[[], Agent] | None = None):
25
+ self._exit_stack = AsyncExitStack()
26
+ self._sessions: dict[str, Agent] = {}
27
+ self._locks: dict[str, asyncio.Lock] = {}
28
+ self._agent_factory = agent_factory or self._default_agent_factory
29
+
30
+ @staticmethod
31
+ def _default_agent_factory() -> Agent:
32
+ config = LocalAgentConfig(
33
+ system_instructions=SYSTEM_INSTRUCTIONS,
34
+ policies=[policy.allow_all()],
35
+ )
36
+ return Agent(config)
37
+
38
+ def get_lock(self, chat_id: str) -> asyncio.Lock:
39
+ """获取会话级并发锁,确保同一 chat_id 消息串行处理。"""
40
+ if chat_id not in self._locks:
41
+ self._locks[chat_id] = asyncio.Lock()
42
+ return self._locks[chat_id]
43
+
44
+ async def get_or_create_agent(self, chat_id: str) -> Agent:
45
+ """获取或初始化会话的 Agent 实例,自动将其上下文托管给 exit_stack。"""
46
+ if chat_id not in self._sessions:
47
+ agent = self._agent_factory()
48
+ managed_agent = await self._exit_stack.enter_async_context(agent)
49
+ self._sessions[chat_id] = managed_agent
50
+ return self._sessions[chat_id]
51
+
52
+ async def close_all(self) -> None:
53
+ """退出时统一安全地逆序清理并释放所有 Agent 资源。"""
54
+ logger.info("正在关闭并释放所有 Agent 会话...")
55
+ await self._exit_stack.aclose()
56
+ self._sessions.clear()
57
+ self._locks.clear()
@@ -0,0 +1,331 @@
1
+ """飞书消息通道模块,负责消息监听、多模态附件下载及主动消息发送。"""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ from typing import Any
7
+
8
+ import yaml
9
+ from lark_channel import (
10
+ Events,
11
+ FeishuChannel,
12
+ FileContent,
13
+ ImageContent,
14
+ InboundMessage,
15
+ )
16
+ from lark_channel.core import LogLevel
17
+
18
+ from .agent import AgentSessionManager
19
+ from .config import Config
20
+ from .utils import create_media_from_bytes, format_iso_time
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class FeishuBotChannel:
26
+ """飞书机器人通道管理类。"""
27
+
28
+ def __init__(
29
+ self,
30
+ config: Config,
31
+ filter_chat_id: str | None = None,
32
+ session_manager: AgentSessionManager | None = None,
33
+ channel: FeishuChannel | None = None,
34
+ ):
35
+ self.config = config
36
+ self.config.validate()
37
+
38
+ self.filter_chat_id = filter_chat_id.strip() if filter_chat_id else None
39
+ self.session_manager = session_manager or AgentSessionManager()
40
+
41
+ if channel:
42
+ self.channel = channel
43
+ else:
44
+ log_level_map = {
45
+ "DEBUG": LogLevel.DEBUG,
46
+ "INFO": LogLevel.INFO,
47
+ "WARNING": LogLevel.WARNING,
48
+ "ERROR": LogLevel.ERROR,
49
+ }
50
+ sdk_log_level = log_level_map.get(
51
+ self.config.log_level.upper(), LogLevel.INFO
52
+ )
53
+ self.channel = FeishuChannel(
54
+ app_id=self.config.lark_app_id,
55
+ app_secret=self.config.lark_app_secret,
56
+ domain=self.config.lark_domain,
57
+ log_level=sdk_log_level,
58
+ )
59
+
60
+ async def _download_inbound_files(self, msg: InboundMessage) -> list[Any]:
61
+ """将消息中的所有文件/多媒体资源下载至内存,并转换为 Antigravity 多模态对象。"""
62
+ media_items: list[Any] = []
63
+ msg_id = msg.id
64
+
65
+ # 1. 优先遍历 resources 列表
66
+ resources = msg.resources or []
67
+ for res in resources:
68
+ try:
69
+ data = await self.channel.download_resource(
70
+ file_key=res.file_key,
71
+ resource_type=res.type,
72
+ message_id=msg_id,
73
+ )
74
+ if data:
75
+ media_obj = create_media_from_bytes(
76
+ data=data,
77
+ file_name=res.file_name,
78
+ resource_type=res.type,
79
+ )
80
+ media_items.append(media_obj)
81
+ logger.info(
82
+ "成功下载附件到内存 | chat_id: %s | 类型: %s | 文件名: %s | 大小: %d 字节",
83
+ msg.chat_id,
84
+ res.type,
85
+ res.file_name or "未指定",
86
+ len(data),
87
+ )
88
+ else:
89
+ logger.warning(
90
+ "下载附件失败 (返回空数据) | file_key: %s", res.file_key
91
+ )
92
+ except Exception as e:
93
+ logger.error("下载附件异常 | file_key: %s: %s", res.file_key, e)
94
+
95
+ # 2. 如果 resources 未解析出资源,兜底检查 content 结构
96
+ if not media_items:
97
+ content = msg.content
98
+ if isinstance(content, FileContent) and getattr(content, "file_key", None):
99
+ try:
100
+ data = await self.channel.download_resource(
101
+ file_key=content.file_key,
102
+ resource_type="file",
103
+ message_id=msg_id,
104
+ )
105
+ if data:
106
+ media_items.append(
107
+ create_media_from_bytes(
108
+ data, content.file_name, "file"
109
+ )
110
+ )
111
+ except Exception as e:
112
+ logger.error("兜底下载 FileContent 异常: %s", e)
113
+ elif isinstance(content, ImageContent) and getattr(content, "image_key", None):
114
+ try:
115
+ data = await self.channel.download_resource(
116
+ file_key=content.image_key,
117
+ resource_type="image",
118
+ message_id=msg_id,
119
+ )
120
+ if data:
121
+ media_items.append(
122
+ create_media_from_bytes(data, None, "image")
123
+ )
124
+ except Exception as e:
125
+ logger.error("兜底下载 ImageContent 异常: %s", e)
126
+
127
+ return media_items
128
+
129
+ async def on_message(self, msg: InboundMessage) -> None:
130
+ """处理飞书收到的消息。"""
131
+ # 忽略机器人自身发送的消息,防止消息回环死循环
132
+ if msg.sender_is_bot:
133
+ return
134
+
135
+ chat_id = msg.chat_id
136
+ msg_id = msg.id
137
+
138
+ # 如果配置了 filter_chat_id,则仅响应该指定的会话
139
+ if self.filter_chat_id and chat_id != self.filter_chat_id:
140
+ logger.debug(
141
+ "跳过非目标会话消息 | 当前 chat_id: %s | 目标 chat_id: %s",
142
+ chat_id,
143
+ self.filter_chat_id,
144
+ )
145
+ return
146
+
147
+ # 消息元数据
148
+ message_metadata = {
149
+ "from": "feishu",
150
+ "chat_id": chat_id,
151
+ "sender_id": msg.sender_id,
152
+ "message_id": msg_id,
153
+ "create_time": format_iso_time(msg.create_time),
154
+ "chat_type": msg.chat_type,
155
+ }
156
+
157
+ # 提取用户文本
158
+ prompt = (msg.content_text or "").strip()
159
+
160
+ # 下载附件到内存中
161
+ media_items = await self._download_inbound_files(msg)
162
+
163
+ if not prompt and not media_items:
164
+ logger.info("收到来自 %s 的空消息且无可用附件,跳过处理", msg.sender_id)
165
+ return
166
+
167
+ logger.info(
168
+ "收到消息 | chat_id: %s | sender_id: %s | content_text: %s | media_items: %d",
169
+ chat_id,
170
+ msg.sender_name or msg.sender_id,
171
+ prompt or "[无文本]",
172
+ len(media_items),
173
+ )
174
+
175
+ # 组装给 Agent 的输入
176
+ agent_input = [
177
+ "---\n"
178
+ + yaml.safe_dump(message_metadata, allow_unicode=True, sort_keys=False)
179
+ + "---\n"
180
+ ]
181
+ if prompt:
182
+ agent_input.append(prompt)
183
+ if media_items:
184
+ agent_input.extend(media_items)
185
+
186
+ lock = self.session_manager.get_lock(chat_id)
187
+
188
+ async with lock:
189
+ typing_reaction_id = None
190
+ try:
191
+ # 提示用户 Agent 正在思考中
192
+ if msg_id:
193
+ typing_reaction_id = await self.channel.add_typing_reaction(msg_id)
194
+
195
+ # 获取常驻 Agent 并生成回复
196
+ agent = await self.session_manager.get_or_create_agent(chat_id)
197
+ response = await agent.chat(agent_input)
198
+
199
+ tokens = []
200
+ stream_succeeded = False
201
+
202
+ # 尝试 CardKit 流式打字机卡片输出
203
+ try:
204
+ async def producer(stream):
205
+ async for token in response:
206
+ tokens.append(token)
207
+ await stream.append(token)
208
+
209
+ await self.channel.stream(chat_id, {"markdown": producer})
210
+ stream_succeeded = True
211
+ except Exception as stream_err:
212
+ logger.warning(
213
+ "流式输出未生效 (%s),自动降级为标准消息发送。"
214
+ "提示:如需打字机卡片流式输出,请在飞书开放平台申请 [cardkit:card:write] 权限。",
215
+ stream_err,
216
+ )
217
+
218
+ # 降级发送完整消息
219
+ if not stream_succeeded:
220
+ async for token in response:
221
+ tokens.append(token)
222
+ full_reply = "".join(tokens)
223
+ if not full_reply and hasattr(response, "text"):
224
+ full_reply = await response.text()
225
+
226
+ if full_reply:
227
+ try:
228
+ await self.channel.send(chat_id, {"markdown": full_reply})
229
+ except Exception:
230
+ await self.channel.send(chat_id, {"text": full_reply})
231
+
232
+ logger.info("回复完成 | chat_id: %s", chat_id)
233
+
234
+ except Exception as e:
235
+ logger.error("Agent 处理出错: %s", e, exc_info=True)
236
+ try:
237
+ await self.channel.send(
238
+ chat_id, {"text": f"抱歉,处理您的请求时出现异常: {e}"}
239
+ )
240
+ except Exception as send_err:
241
+ logger.error("发送错误提示失败: %s", send_err)
242
+ finally:
243
+ if typing_reaction_id and msg_id:
244
+ await self.channel.remove_typing_reaction(
245
+ msg_id, typing_reaction_id
246
+ )
247
+
248
+ async def start_listening(self) -> None:
249
+ """启动飞书长连接监听服务。"""
250
+ self.channel.on(Events.MESSAGE, self.on_message)
251
+
252
+ try:
253
+ filter_hint = f" (过滤会话: {self.filter_chat_id})" if self.filter_chat_id else ""
254
+ logger.info("飞书机器人服务启动中,正在连接长连接网关%s...", filter_hint)
255
+ await self.channel.connect()
256
+ except (asyncio.CancelledError, KeyboardInterrupt):
257
+ pass
258
+ finally:
259
+ await self.close()
260
+
261
+ async def close(self) -> None:
262
+ """关闭通道与释放所有资源。"""
263
+ self.channel.stop()
264
+ await self.session_manager.close_all()
265
+ logger.info("飞书机器人已安全断开并退出。")
266
+
267
+ async def send_message(
268
+ self,
269
+ chat_id: str,
270
+ message: str | None = None,
271
+ file_paths: list[str] | str | None = None,
272
+ message_type: str = "markdown",
273
+ ) -> Any:
274
+ """主动向飞书会话发送消息或多个文件 (支持文件与文本组合发送)。"""
275
+ results = []
276
+
277
+ # 归一化为文件路径列表
278
+ paths: list[str] = []
279
+ if file_paths:
280
+ if isinstance(file_paths, str):
281
+ paths.append(file_paths)
282
+ else:
283
+ paths.extend(file_paths)
284
+
285
+ for fp in paths:
286
+ if not os.path.exists(fp):
287
+ raise FileNotFoundError(f"未找到待发送的文件: {fp}")
288
+
289
+ with open(fp, "rb") as f:
290
+ file_bytes = f.read()
291
+
292
+ file_name = os.path.basename(fp)
293
+ dot_index = file_name.rfind(".")
294
+ ext = file_name[dot_index:].lower() if dot_index != -1 else ""
295
+
296
+ # 判断是否为图片
297
+ if ext in {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}:
298
+ # 仅在仅发送单张图片且附带 message 时,合并为图文消息
299
+ if len(paths) == 1 and message:
300
+ logger.info("正在向 %s 发送带说明的图片文件: %s", chat_id, file_name)
301
+ res = await self.channel.send(
302
+ chat_id,
303
+ {"image": {"source": file_bytes}, "caption": message},
304
+ )
305
+ results.append(res)
306
+ message = None # 图片已带 caption,无需重复发送单独的文本
307
+ else:
308
+ logger.info("正在向 %s 发送图片文件: %s", chat_id, file_name)
309
+ res = await self.channel.send(
310
+ chat_id,
311
+ {"image": {"source": file_bytes}},
312
+ )
313
+ results.append(res)
314
+ else:
315
+ logger.info("正在向 %s 发送附件文件: %s", chat_id, file_name)
316
+ res = await self.channel.send(
317
+ chat_id,
318
+ {"file": {"source": file_bytes, "file_name": file_name}},
319
+ )
320
+ results.append(res)
321
+
322
+ # 发送附带的文本/Markdown 消息
323
+ if message:
324
+ logger.info("正在向 %s 发送消息: %s", chat_id, message)
325
+ if message_type == "text":
326
+ res = await self.channel.send(chat_id, {"text": message})
327
+ else:
328
+ res = await self.channel.send(chat_id, {"markdown": message})
329
+ results.append(res)
330
+
331
+ return results[0] if len(results) == 1 else results
@@ -0,0 +1,224 @@
1
+ import argparse
2
+ import asyncio
3
+ import contextlib
4
+ import sys
5
+
6
+ from .channel import FeishuBotChannel
7
+ from .config import Config
8
+ from .utils import setup_logging
9
+
10
+
11
+ def create_parser() -> argparse.ArgumentParser:
12
+ """构建 CLI 参数解析器。"""
13
+ parser = argparse.ArgumentParser(
14
+ description="基于 Antigravity SDK 的飞书私人 AI 助手工具",
15
+ )
16
+ parser.add_argument(
17
+ "--log-level",
18
+ default=None,
19
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
20
+ help="日志输出级别 (覆盖环境变量 LOG_LEVEL,默认: INFO)",
21
+ )
22
+
23
+ subparsers = parser.add_subparsers(
24
+ dest="command",
25
+ required=True,
26
+ help="支持的子命令",
27
+ )
28
+
29
+ # 1. 监听消息子命令 (listen)
30
+ listen_parser = subparsers.add_parser(
31
+ "listen",
32
+ aliases=["serve"],
33
+ help="启动长连接网关监听飞书消息并调用 Agent 回复",
34
+ )
35
+ listen_parser.add_argument(
36
+ "--chat-id",
37
+ "-c",
38
+ dest="chat_id",
39
+ default=None,
40
+ help="可选。若指定则仅响应该 chat_id 的消息,未指定则响应所有群/单聊",
41
+ )
42
+
43
+ # 2. 命令行主动发送消息子命令 (send)
44
+ send_parser = subparsers.add_parser(
45
+ "send",
46
+ help="主动向指定飞书会话发送消息或文件",
47
+ )
48
+ send_parser.add_argument(
49
+ "--chat-id",
50
+ "-c",
51
+ dest="chat_id",
52
+ required=True,
53
+ help="必填。目标飞书会话 ID (chat_id)",
54
+ )
55
+ msg_group = send_parser.add_mutually_exclusive_group()
56
+ msg_group.add_argument(
57
+ "--message",
58
+ "-m",
59
+ dest="message",
60
+ default=None,
61
+ help="要发送的消息内容 (支持 Markdown)。若指定为 '-' 则从标准输入读取",
62
+ )
63
+ msg_group.add_argument(
64
+ "--stdin",
65
+ "-s",
66
+ dest="read_stdin",
67
+ action="store_true",
68
+ help="从标准输入 (stdin) 读取消息内容",
69
+ )
70
+ send_parser.add_argument(
71
+ "--file",
72
+ "-f",
73
+ dest="file_paths",
74
+ nargs="+",
75
+ default=None,
76
+ help="要发送的本地文件路径 (支持同时指定多个文件)",
77
+ )
78
+ send_parser.add_argument(
79
+ "--type",
80
+ "-t",
81
+ dest="message_type",
82
+ choices=["markdown", "text"],
83
+ default="markdown",
84
+ help="消息类型: markdown (默认) 或 text",
85
+ )
86
+
87
+ return parser
88
+
89
+
90
+ def read_stdin_content() -> str:
91
+ """从标准输入读取消息内容,强制使用 UTF-8 编码解码以避免 Windows 默认编码(如 CP936/GBK)导致的乱码。"""
92
+ if hasattr(sys.stdin, "buffer"):
93
+ with contextlib.suppress(AttributeError, ValueError):
94
+ raw_bytes = sys.stdin.buffer.read()
95
+ try:
96
+ return raw_bytes.decode("utf-8-sig")
97
+ except UnicodeDecodeError:
98
+ return raw_bytes.decode(errors="replace")
99
+
100
+ if hasattr(sys.stdin, "reconfigure"):
101
+ with contextlib.suppress(Exception):
102
+ sys.stdin.reconfigure(encoding="utf-8")
103
+
104
+ return sys.stdin.read()
105
+
106
+
107
+ def resolve_send_message(args: argparse.Namespace) -> str | None:
108
+ """从命令行参数或标准输入中解析待发送的消息内容。"""
109
+ is_stdin_requested = getattr(args, "read_stdin", False) or args.message == "-"
110
+
111
+ if is_stdin_requested:
112
+ if sys.stdin.isatty():
113
+ eof_hint = "Ctrl+Z 回车" if sys.platform == "win32" else "Ctrl+D"
114
+ print(
115
+ f"[INFO] 正在从标准输入读取消息内容 (按 {eof_hint} 结束输入)...",
116
+ file=sys.stderr,
117
+ )
118
+ content = read_stdin_content()
119
+ if not content.strip() and not args.file_paths:
120
+ raise ValueError("从标准输入读取到的内容为空。")
121
+ return content
122
+
123
+ if args.message is not None:
124
+ return args.message
125
+
126
+ # 当未显式指定 --message / --stdin 且未指定 --file 时,若检测到管道/重定向输入,则自动读取 stdin
127
+ if not args.file_paths and not sys.stdin.isatty():
128
+ content = read_stdin_content()
129
+ return content if content.strip() else None
130
+
131
+ return None
132
+
133
+
134
+ async def run_listen(chat_id: str | None, config: Config) -> None:
135
+ """运行监听服务。"""
136
+ channel = FeishuBotChannel(config=config, filter_chat_id=chat_id)
137
+ await channel.start_listening()
138
+
139
+
140
+ async def run_send(
141
+ chat_id: str,
142
+ message: str | None,
143
+ file_paths: list[str] | None,
144
+ message_type: str,
145
+ config: Config,
146
+ ) -> None:
147
+ """运行主动发送消息。"""
148
+ if message == "-":
149
+ if sys.stdin.isatty():
150
+ eof_hint = "Ctrl+Z 回车" if sys.platform == "win32" else "Ctrl+D"
151
+ print(
152
+ f"[INFO] 正在从标准输入读取消息内容 (按 {eof_hint} 结束输入)...",
153
+ file=sys.stderr,
154
+ )
155
+ content = read_stdin_content()
156
+ if not content.strip() and not file_paths:
157
+ raise ValueError("从标准输入读取到的内容为空。")
158
+ message = content
159
+
160
+ if not message and not file_paths:
161
+ raise ValueError("主动发送消息必须提供 --message、--stdin 或 --file 至少一项参数。")
162
+
163
+ channel = FeishuBotChannel(config=config)
164
+ try:
165
+ res = await channel.send_message(
166
+ chat_id=chat_id,
167
+ message=message,
168
+ file_paths=file_paths,
169
+ message_type=message_type,
170
+ )
171
+ print(f"[SUCCESS] 消息发送成功: {res}")
172
+ finally:
173
+ await channel.close()
174
+
175
+
176
+ def main(argv: list[str] | None = None) -> int:
177
+ """CLI 主入口函数。"""
178
+ if hasattr(sys.stdout, "reconfigure"):
179
+ with contextlib.suppress(Exception):
180
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
181
+ if hasattr(sys.stderr, "reconfigure"):
182
+ with contextlib.suppress(Exception):
183
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
184
+
185
+ parser = create_parser()
186
+ args = parser.parse_args(argv)
187
+
188
+ config = Config.from_env()
189
+ if args.log_level:
190
+ config.log_level = args.log_level
191
+
192
+ setup_logging(config.log_level)
193
+
194
+ try:
195
+ config.validate()
196
+ except ValueError as e:
197
+ print(f"[ERROR] 配置错误: {e}", file=sys.stderr)
198
+ return 1
199
+
200
+ try:
201
+ if args.command in ("listen", "serve"):
202
+ asyncio.run(run_listen(args.chat_id, config))
203
+ elif args.command == "send":
204
+ message = resolve_send_message(args)
205
+ asyncio.run(
206
+ run_send(
207
+ chat_id=args.chat_id,
208
+ message=message,
209
+ file_paths=args.file_paths,
210
+ message_type=args.message_type,
211
+ config=config,
212
+ )
213
+ )
214
+ return 0
215
+ except KeyboardInterrupt:
216
+ print("\n[INFO] 用户中断,程序已退出。")
217
+ return 0
218
+ except Exception as e:
219
+ print(f"[ERROR] 执行失败: {e}", file=sys.stderr)
220
+ return 1
221
+
222
+
223
+ if __name__ == "__main__":
224
+ sys.exit(main())
@@ -0,0 +1,41 @@
1
+ """配置管理模块。"""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ from dotenv import load_dotenv
7
+
8
+ # 加载 .env 环境变量
9
+ load_dotenv(override=True)
10
+
11
+
12
+ @dataclass
13
+ class Config:
14
+ """机器人与服务配置项。"""
15
+
16
+ lark_app_id: str = ""
17
+ lark_app_secret: str = ""
18
+ lark_domain: str | None = None
19
+ log_level: str = "INFO"
20
+
21
+ @classmethod
22
+ def from_env(cls) -> "Config":
23
+ """从环境变量读取配置。"""
24
+ return cls(
25
+ lark_app_id=os.getenv("LARK_APP_ID", "").strip(),
26
+ lark_app_secret=os.getenv("LARK_APP_SECRET", "").strip(),
27
+ lark_domain=os.getenv("LARK_DOMAIN"),
28
+ log_level=os.getenv("LOG_LEVEL", "INFO").strip(),
29
+ )
30
+
31
+ def validate(self) -> None:
32
+ """校验关键凭据是否已配置。"""
33
+ if not self.lark_app_id or not self.lark_app_secret:
34
+ raise ValueError(
35
+ "请在环境变量或 .env 文件中配置 LARK_APP_ID 与 LARK_APP_SECRET"
36
+ )
37
+
38
+
39
+ def get_config() -> Config:
40
+ """获取配置实例。"""
41
+ return Config.from_env()
@@ -0,0 +1,117 @@
1
+ """工具类模块,提供日志配置与内存文件解析功能。"""
2
+
3
+ import logging
4
+ import mimetypes
5
+ from datetime import datetime
6
+
7
+ from google.antigravity import types
8
+
9
+
10
+ def format_iso_time(timestamp: float | str | None) -> str:
11
+ """将时间戳(毫秒或秒)转换为带当前时区的 ISO 8601 标准时间字符串。"""
12
+ if timestamp is None or timestamp == "":
13
+ return ""
14
+ try:
15
+ ts = float(timestamp)
16
+ # 兼容毫秒级时间戳(如 13 位数字,> 10^11)
17
+ if ts > 1e11:
18
+ ts /= 1000.0
19
+ return datetime.fromtimestamp(ts).astimezone().isoformat()
20
+ except Exception:
21
+ return str(timestamp)
22
+
23
+
24
+ def setup_logging(level: str = "INFO") -> None:
25
+ """初始化标准 logging 配置。"""
26
+ numeric_level = getattr(logging, level.upper(), logging.INFO)
27
+ logging.basicConfig(
28
+ level=numeric_level,
29
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
30
+ datefmt="%Y-%m-%d %H:%M:%S",
31
+ )
32
+
33
+
34
+ # 常见纯文本/代码文件扩展名
35
+ TEXT_EXTENSIONS = {
36
+ ".txt", ".md", ".py", ".json", ".csv", ".xml", ".html", ".htm",
37
+ ".css", ".js", ".ts", ".jsx", ".tsx", ".yaml", ".yml", ".toml",
38
+ ".ini", ".cfg", ".conf", ".sh", ".bash", ".bat", ".ps1", ".sql",
39
+ ".env", ".c", ".cpp", ".cc", ".h", ".hpp", ".java", ".rs", ".go",
40
+ ".php", ".rb", ".log",
41
+ }
42
+
43
+
44
+ def guess_mime_type(file_name: str | None, resource_type: str = "file") -> str:
45
+ """根据文件名与资源类型推断 MIME 类型。"""
46
+ if file_name:
47
+ dot_index = file_name.rfind(".")
48
+ ext = file_name[dot_index:].lower() if dot_index != -1 else ""
49
+
50
+ if ext == ".json":
51
+ return "application/json"
52
+ if ext == ".csv":
53
+ return "text/csv"
54
+ if ext in {".html", ".htm"}:
55
+ return "text/html"
56
+ if ext == ".css":
57
+ return "text/css"
58
+ if ext == ".js":
59
+ return "text/javascript"
60
+ if ext == ".xml":
61
+ return "text/xml"
62
+ if ext == ".pdf":
63
+ return "application/pdf"
64
+ if ext in TEXT_EXTENSIONS:
65
+ return "text/plain"
66
+
67
+ guessed, _ = mimetypes.guess_type(file_name)
68
+ if guessed:
69
+ return guessed
70
+
71
+ if resource_type == "image":
72
+ return "image/png"
73
+ if resource_type == "audio":
74
+ return "audio/mp3"
75
+ if resource_type == "video":
76
+ return "video/mp4"
77
+
78
+ return "application/octet-stream"
79
+
80
+
81
+ def create_media_from_bytes(
82
+ data: bytes,
83
+ file_name: str | None = None,
84
+ resource_type: str = "file",
85
+ ) -> types.Image | types.Document | types.Audio | types.Video | str:
86
+ """
87
+ 将内存中的二进制文件字节转换为 Antigravity SDK 原生识别的多模态对象。
88
+ 若属于 Gemini SDK 支持的文件格式,直接构造对应的 Image / Document / Audio / Video;
89
+ 若为纯文本内容但扩展名未知,尝试 UTF-8 解码转为 Document(text/plain);
90
+ 若完全不支持,则降级为带有文件描述的文本提示。
91
+ """
92
+ mime_type = guess_mime_type(file_name, resource_type)
93
+
94
+ # 1. 尝试使用 types.from_bytes 构造(自动映射到 Image / Document / Audio / Video)
95
+ try:
96
+ return types.from_bytes(data=data, mime_type=mime_type, description=file_name)
97
+ except ValueError:
98
+ pass
99
+
100
+ # 2. 尝试以 UTF-8 解码为纯文本 Document
101
+ try:
102
+ _ = data.decode("utf-8")
103
+ return types.Document(data=data, mime_type="text/plain", description=file_name)
104
+ except UnicodeDecodeError:
105
+ pass
106
+
107
+ # 3. Magic Number 兜底识别
108
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
109
+ return types.Image(data=data, mime_type="image/png", description=file_name)
110
+ if data.startswith(b"\xff\xd8\xff"):
111
+ return types.Image(data=data, mime_type="image/jpeg", description=file_name)
112
+ if data.startswith(b"%PDF"):
113
+ return types.Document(data=data, mime_type="application/pdf", description=file_name)
114
+
115
+ # 4. 无法直接解析的二进制文件,降级为文本提示
116
+ desc = file_name or "未知文件名"
117
+ return f"[用户上传了附件文件: {desc},大小: {len(data)} 字节,暂不支持该格式直接解析]"