feishu_fabu/feishu_bot.py

732 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
飞书机器人消息接收与处理脚本
接收飞书消息,解析内容,调用 WordPress 发布脚本
"""
import os
import sys
import json
import time
import logging
from datetime import datetime
# 添加项目根目录到 Python 路径
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BASE_DIR)
# 导入飞书 API 客户端
from modules.feishu_api import create_feishu_client
# 分类映射slug -> ID
CATEGORY_MAP = {
'ai-kepu': 12,
'ai-zixun': 11,
'geo': 16,
'ai': 9,
'jishu': 5,
'fenxiang': 10,
'wenzhang': 4,
'zaji': 8,
'suibi': 7,
'guanyu': 1
}
# 配置日志
LOG_DIR = os.path.join(BASE_DIR, 'logs')
os.makedirs(LOG_DIR, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(os.path.join(LOG_DIR, 'feishu_bot.log'), encoding='utf-8'),
logging.StreamHandler()
]
)
logger = logging.getLogger('feishu_bot')
class FeishuBot:
"""飞书机器人"""
def __init__(self):
"""初始化飞书机器人"""
self.config = self._load_config()
self.wp_script = os.path.join(BASE_DIR, 'scripts', 'wp_publish_text.py')
self.word_script = os.path.join(BASE_DIR, 'scripts', 'wp_publish.py')
# 初始化飞书 API 客户端
self.feishu_client = create_feishu_client(
app_id=self.config.get('app_id'),
app_secret=self.config.get('app_secret')
)
logger.info("🤖 飞书机器人初始化完成")
logger.info(f" WordPress 发布脚本:{self.wp_script}")
logger.info(f" Word 发布脚本:{self.word_script}")
logger.info(f" 飞书 API 客户端:已初始化")
def _load_config(self):
"""加载配置"""
try:
from feishu_config import (
FEISHU_APP_ID, FEISHU_APP_SECRET,
SERVER_HOST, SERVER_PORT, ALLOWED_USERS
)
return {
'app_id': FEISHU_APP_ID,
'app_secret': FEISHU_APP_SECRET,
'host': SERVER_HOST,
'port': SERVER_PORT,
'allowed_users': ALLOWED_USERS
}
except ImportError:
logger.warning("未找到 feishu_config.py使用默认配置")
return {
'app_id': '',
'app_secret': '',
'host': '0.0.0.0',
'port': 8080,
'allowed_users': []
}
def process_message(self, message):
"""
处理接收到的消息
Args:
message: 飞书消息对象
Returns:
str: 回复消息
"""
try:
# 解析消息内容
msg_type = message.get('msg_type', '')
content = message.get('content', '')
sender_id = message.get('sender', {}).get('sender_id', '')
message_id = message.get('message_id', '')
chat_id = message.get('chat_id', '')
logger.info(f"📨 收到消息 - 类型:{msg_type}, 发送者:{sender_id}, 消息 ID: {message_id}")
# 检查权限
if self.config['allowed_users'] and sender_id not in self.config['allowed_users']:
return "⚠️ 您没有权限使用此机器人"
# 处理不同类型的消息
if msg_type == 'text':
return self._handle_text_message(content, sender_id, message_id, chat_id)
elif msg_type == 'image':
return self._handle_image_message(content, sender_id, message_id, chat_id)
elif msg_type == 'file':
return self._handle_file_message(content, sender_id, message_id, chat_id)
elif msg_type == 'interactive':
return self._handle_interactive_message(content, sender_id, message_id, chat_id)
else:
return f"📝 暂不支持的消息类型:{msg_type}"
except Exception as e:
logger.error(f"❌ 处理消息失败:{str(e)}", exc_info=True)
return f"❌ 处理消息失败:{str(e)}"
def _handle_text_message(self, content, sender_id, message_id=None, chat_id=None):
"""
处理文字消息
Args:
content: 消息内容
sender_id: 发送者 ID
message_id: 消息 ID用于获取图片
chat_id: 聊天 ID
Returns:
str: 回复消息
"""
logger.info(f"📝 处理文字消息")
# 解析指令
instruction = self._parse_instruction(content)
# 获取消息中的图片(如果有)
images = []
if message_id:
images = self._get_message_images(message_id)
# 检查是否为发布指令
if instruction.get('action') == 'publish':
return self._publish_article(
text=instruction.get('text', ''),
title=instruction.get('title', ''),
category=instruction.get('category', ''),
tags=instruction.get('tags', ''),
status=instruction.get('status', 'publish'),
images=images if images else None
)
elif instruction.get('action') == 'update':
return self._update_article(
target=instruction.get('target', ''),
text=instruction.get('text', ''),
title=instruction.get('title', ''),
status=instruction.get('status', 'publish')
)
elif instruction.get('action') == 'help':
return self._get_help_message()
elif instruction.get('action') == 'status':
return self._get_status_message()
else:
# 默认发布
return self._publish_article(text=content, images=images if images else None)
def _get_message_images(self, message_id):
"""
获取消息中的图片列表
Args:
message_id: 消息 ID
Returns:
list: 图片本地路径列表
"""
if not message_id:
return []
logger.info(f"🔍 获取消息图片 - Message ID: {message_id}")
try:
# 使用飞书 API 客户端获取图片
images = self.feishu_client.get_message_images(message_id)
if not images:
logger.info("消息中没有图片")
return []
# 下载所有图片
downloaded_images = []
for img in images:
image_key = img.get('image_key', '')
if image_key:
image_path = self._download_image(image_key)
if image_path:
downloaded_images.append(image_path)
logger.info(f"✅ 图片下载成功:{image_path}")
else:
logger.error(f"❌ 图片下载失败:{image_key}")
logger.info(f"📊 共下载 {len(downloaded_images)} 张图片")
return downloaded_images
except Exception as e:
logger.error(f"获取消息图片失败:{str(e)}")
return []
def _handle_image_message(self, content, sender_id, message_id=None, chat_id=None):
"""
处理图片消息
Args:
content: 消息内容
sender_id: 发送者 ID
message_id: 消息 ID用于获取图片
chat_id: 聊天 ID
Returns:
str: 回复消息
"""
logger.info(f"🖼️ 处理图片消息")
try:
image_key = json.loads(content).get('image_key', '')
# 下载图片
image_path = self._download_image(image_key)
if image_path:
# 发布带图片的文章(保留原始文字内容)
return self._publish_article(
text="图片文章", # 默认文字,如果有文字消息会保留
images=[image_path],
message_id=message_id,
chat_id=chat_id
)
else:
return "❌ 图片下载失败"
except Exception as e:
logger.error(f"处理图片消息失败:{str(e)}")
return f"❌ 处理图片消息失败:{str(e)}"
def _handle_file_message(self, content, sender_id, message_id=None, chat_id=None):
"""
处理文件消息
Args:
content: 消息内容
sender_id: 发送者 ID
message_id: 消息 ID
chat_id: 聊天 ID
Returns:
str: 回复消息
"""
logger.info(f"📁 处理文件消息")
try:
file_info = json.loads(content)
file_key = file_info.get('file_key', '')
file_name = file_info.get('file_name', '')
# 检查是否为 Word 文档
if not file_name.endswith('.docx'):
return "⚠️ 仅支持 .docx 格式的 Word 文档"
# 下载文件
file_path = self._download_file(file_key, file_name)
if file_path:
# 发布 Word 文档
return self._publish_word_document(file_path)
else:
return "❌ 文件下载失败"
except Exception as e:
logger.error(f"处理文件消息失败:{str(e)}")
return f"❌ 处理文件消息失败:{str(e)}"
def _handle_interactive_message(self, content, sender_id, message_id=None, chat_id=None):
"""
处理交互式消息
Args:
content: 消息内容
sender_id: 发送者 ID
message_id: 消息 ID
chat_id: 聊天 ID
Returns:
str: 回复消息
"""
logger.info(f"🔄 处理交互式消息")
try:
data = json.loads(content)
action = data.get('action', '')
if action == 'publish':
return self._publish_article(
text=data.get('text', ''),
title=data.get('title', ''),
category=data.get('category', '')
)
else:
return f"⚠️ 未知的交互动作:{action}"
except Exception as e:
logger.error(f"处理交互式消息失败:{str(e)}")
return f"❌ 处理交互式消息失败:{str(e)}"
def _parse_instruction(self, content):
"""
解析消息指令
Args:
content: 消息内容
Returns:
dict: 解析结果
"""
instruction = {
'action': 'publish', # 默认动作:发布
'text': '',
'title': '',
'category': '',
'tags': '',
'status': 'publish'
}
lines = content.strip().split('\n')
text_lines = []
for line in lines:
line = line.strip()
# 解析指令
if line.startswith('#标题'):
instruction['title'] = line.replace('#标题', '').strip()
elif line.startswith('#分类') or line.startswith('#category'):
instruction['category'] = line.replace('#分类', '').replace('#category', '').strip()
elif line.startswith('#标签') or line.startswith('#tag'):
instruction['tags'] = line.replace('#标签', '').replace('#tag', '').strip()
elif line.startswith('#状态') or line.startswith('#status'):
status = line.replace('#状态', '').replace('#status', '').strip().lower()
if status in ['publish', 'draft', 'pending', 'private']:
instruction['status'] = status
elif line.startswith('#发布'):
instruction['action'] = 'publish'
elif line.startswith('#草稿'):
instruction['status'] = 'draft'
instruction['action'] = 'publish'
elif line.startswith('#帮助') or line.startswith('#help'):
instruction['action'] = 'help'
elif line.startswith('#状态') or line.startswith('#status'):
instruction['action'] = 'status'
elif line.startswith('#更新') or line.startswith('#update'):
instruction['action'] = 'update'
instruction['target'] = line.replace('#更新', '').replace('#update', '').strip()
else:
text_lines.append(line)
instruction['text'] = '\n'.join(text_lines)
return instruction
def _resolve_category(self, category_slug):
"""
将分类 slug 转换为 ID
Args:
category_slug: 分类 slug
Returns:
int: 分类 ID
"""
if not category_slug:
return None
# 先尝试直接匹配
slug = category_slug.lower().strip()
if slug in CATEGORY_MAP:
return CATEGORY_MAP[slug]
# 尝试模糊匹配
for key, value in CATEGORY_MAP.items():
if slug in key or key in slug:
return value
# 如果输入的是数字,直接返回
try:
return int(slug)
except ValueError:
pass
# 默认返回随笔分类
logger.warning(f"未找到分类:{category_slug},使用默认分类")
return CATEGORY_MAP.get('suibi', 7)
def _publish_article(self, text='', title='', category='', tags='', images=None, status='publish', message_id=None, chat_id=None):
"""
发布文章(直接调用 Python 函数,避免 shell 引号问题)
Args:
text: 文章正文
title: 文章标题
category: 分类
tags: 标签
images: 图片列表
status: 发布状态
message_id: 消息 ID
chat_id: 聊天 ID
Returns:
str: 回复消息
"""
if not text:
return "⚠️ 文章内容不能为空"
logger.info(f"📝 准备发布文章 - 标题:{title}, 分类:{category}")
try:
# 直接导入并调用 Python 函数(避免 subprocess 的 shell 转义问题)
from scripts.wp_publish_text import publish_text_with_images
# 解析分类(如果是 slug 需要转换为 ID
category_id = self._resolve_category(category) if category else None
# 调用发布函数
publish_result = publish_text_with_images(
text=text,
images=images,
instruction=f"#分类 {category}" if category else None,
status=status,
category_id=category_id,
title=title if title else None
)
# 处理结果
if publish_result.get('success'):
post_url = publish_result.get('post_url', '')
post_id = publish_result.get('post_id', '')
reply = "✅ 文章发布成功!\n"
reply += f"📝 标题:{publish_result.get('title', title or '自动提取')}\n"
reply += f"🔗 链接:{post_url}\n"
reply += f"📊 文章 ID{post_id}"
if publish_result.get('images_uploaded', 0) > 0:
reply += f"\n🖼️ 已上传图片:{publish_result['images_uploaded']}"
return reply
else:
error_msg = publish_result.get('error', '未知错误')
return f"❌ 发布失败:{error_msg}"
except Exception as e:
logger.error(f"发布文章失败:{str(e)}", exc_info=True)
return f"❌ 发布失败:{str(e)}"
def _update_article(self, target='', text='', title='', status='publish'):
"""
更新已有文章(追加内容模式)
Args:
target: 文章 ID 或标题关键词
text: 新增内容
title: 新标题(可选)
status: 发布状态
Returns:
str: 回复消息
"""
if not target:
return "⚠️ 请指定文章 ID 或标题,例如:\n`#更新 12345`\n`#更新 文章标题`"
if not text:
return "⚠️ 更新内容不能为空"
logger.info(f"🔄 准备更新文章 - 目标:{target}")
try:
from scripts.wp_publish_text import update_post_with_text
result = update_post_with_text(
target=target,
new_text=text,
new_title=title if title else None,
status=status
)
if result.get('success'):
reply = "✅ 文章更新成功!\n"
reply += f"📝 标题:{result.get('title')}\n"
reply += f"🔗 链接:{result.get('post_url')}\n"
reply += f"📊 文章 ID{result.get('post_id')}"
return reply
else:
return f"❌ 更新失败:{result.get('error')}"
except Exception as e:
logger.error(f"更新文章失败:{str(e)}", exc_info=True)
return f"❌ 更新失败:{str(e)}"
def _publish_word_document(self, file_path):
"""
发布 Word 文档(直接调用 Python 函数)
Args:
file_path: Word 文档路径
Returns:
str: 回复消息
"""
logger.info(f"📄 准备发布 Word 文档:{file_path}")
try:
# 直接导入并调用 Python 函数
from scripts.wp_publish import publish_word_document
# 调用发布函数
publish_result = publish_word_document(word_file_path=file_path)
# 处理结果
if publish_result.get('success'):
post_url = publish_result.get('post_url', '')
post_id = publish_result.get('post_id', '')
title = publish_result.get('title', '自动提取')
reply = "✅ Word 文档发布成功!\n"
reply += f"📝 标题:{title}\n"
reply += f"🔗 链接:{post_url}\n"
reply += f"📊 文章 ID{post_id}"
if publish_result.get('images_uploaded', 0) > 0:
reply += f"\n🖼️ 已上传图片:{publish_result['images_uploaded']}"
return reply
else:
error_msg = publish_result.get('error', '未知错误')
return f"❌ 发布失败:{error_msg}"
except Exception as e:
logger.error(f"发布 Word 文档失败:{str(e)}", exc_info=True)
return f"❌ 发布失败:{str(e)}"
def _get_help_message(self):
"""
获取帮助信息
Returns:
str: 帮助消息
"""
help_text = """🤖 **WordPress 发布助手 - 使用说明**
📝 **发布文字文章**
直接发送文字内容即可发布
📄 **发布 Word 文档**
发送 .docx 格式的 Word 文档
🖼️ **发布图片文章**
发送图片即可发布
---
**指令说明**
`#标题 文章标题` - 指定文章标题
`#分类 分类名` - 指定分类(如:#分类 ai
`#标签 标签名` - 指定标签
`#草稿` - 保存为草稿
`#发布` - 立即发布(默认)
`#更新 ID或标题` - 更新已有文章(追加内容)
---
**示例**
```
#标题 AI 发展趋势
#分类 ai
人工智能正在改变世界...
```
**更新示例**
```
#更新 12345
新增的内容追加到原文末尾...
```
```
#更新 AI发展趋势
#标题 AI 发展趋势 2026 更新版
补充一段关于 Agent 的最新进展...
```
**可用分类**
- ai - 人工智能
- ai-kepu - Ai 科普
- ai-zixun - Ai 资讯
- geo - GEO
- jishu - 技术资料
- fenxiang - 好物分享
- wenzhang - 文章分享
- zaji - 杂记
- suibi - 随笔(默认)
发送 `#帮助` 查看此消息"""
return help_text
def _get_status_message(self):
"""
获取系统状态
Returns:
str: 状态消息
"""
status_text = f"""📊 **系统状态**
- **时间**{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
- **状态**:✅ 运行中
- **WordPress**https://www.nanlou.net
- **发布脚本**:已就绪
发送 `#帮助` 查看使用说明"""
return status_text
def _download_image(self, image_key):
"""
下载飞书图片到本地
Args:
image_key: 图片 key
Returns:
str: 图片本地路径
"""
if not image_key:
logger.warning("图片 key 为空")
return None
# 使用飞书 API 客户端下载图片
save_dir = os.path.join(BASE_DIR, 'temp')
image_path = self.feishu_client.download_image(image_key, save_dir)
if image_path:
logger.info(f"✅ 图片下载成功:{image_path}")
return image_path
else:
logger.error("❌ 图片下载失败")
return None
def _download_file(self, file_key, file_name):
"""
下载飞书文件到本地
Args:
file_key: 文件 key
file_name: 文件名
Returns:
str: 文件本地路径
"""
if not file_key:
logger.warning("文件 key 为空")
return None
# 使用飞书 API 客户端下载文件
save_dir = os.path.join(BASE_DIR, 'temp')
file_path = self.feishu_client.download_file(file_key, file_name, save_dir)
if file_path:
logger.info(f"✅ 文件下载成功:{file_path}")
return file_path
else:
logger.error("❌ 文件下载失败")
return None
# 创建机器人实例
bot = FeishuBot()
def handle_message(message):
"""
处理消息入口函数
Args:
message: 飞书消息字典
Returns:
str: 回复消息
"""
return bot.process_message(message)
if __name__ == '__main__':
# 测试模式
print("🤖 飞书机器人测试模式")
print("=" * 50)
# 测试文字消息
test_message = {
'msg_type': 'text',
'content': '#标题 测试文章\n#分类 ai\n这是测试内容',
'sender': {'sender_id': 'test_user'}
}
reply = bot.process_message(test_message)
print(f"\n📨 回复:\n{reply}")