Hyperf方案 飞书机器人智能客服= 基于 Hyperf + 飞书机器人事件订阅,实现接收用户消息 → 调用 Claude/GPT → 回复的完整链路
【代码】Hyperf方案 飞书机器人智能客服= 基于 Hyperf + 飞书机器人事件订阅,实现接收用户消息 → 调用 Claude/GPT → 回复的完整链路。
·
整体流程
用户发消息 → 飞书事件推送 → 验签解密 → 读取上下文 → 调用 Claude → 流式回复 → 存上下文
---
1. 配置
config/autoload/feishu.php 追加:
'bot' => [
'encrypt_key' => env('FEISHU_ENCRYPT_KEY', ''),
'verification_token' => env('FEISHU_VERIFY_TOKEN', ''),
],
config/autoload/ai.php
return [
'anthropic' => [
'api_key' => env('ANTHROPIC_API_KEY', ''),
'model' => 'claude-opus-4-6',
'max_tokens' => 2048,
],
];
---
2. 上下文管理
app/Service/Bot/ContextManager.php
<?php
namespace App\Service\Bot;
use Psr\SimpleCache\CacheInterface;
class ContextManager
{
private const TTL = 1800; // 30 分钟无消息清空上下文
private const MAX_MSG = 20; // 最多保留 20 条历史
public function __construct(private CacheInterface $cache) {}
public function get(string $sessionId): array
{
return $this->cache->get($this->key($sessionId), []);
}
public function append(string $sessionId, string $role, string $content): void
{
$messages = $this->get($sessionId);
$messages[] = ['role' => $role, 'content' => $content];
// 超出上限时丢弃最早的一问一答(保留 system prompt 不动)
if (count($messages) > self::MAX_MSG) {
array_splice($messages, 0, 2);
}
$this->cache->set($this->key($sessionId), $messages, self::TTL);
}
public function clear(string $sessionId): void
{
$this->cache->delete($this->key($sessionId));
}
private function key(string $sessionId): string
{
return 'feishu:bot:ctx:' . $sessionId;
}
}
---
3. Claude 调用服务(流式)
app/Service/Bot/ClaudeService.php
<?php
namespace App\Service\Bot;
use Hyperf\Guzzle\ClientFactory;
use Hyperf\Contract\ConfigInterface;
class ClaudeService
{
private const SYSTEM_PROMPT = <<<PROMPT
你是一个专业的智能客服助手,回答简洁、准确、友好。
如果不确定答案,请如实告知,不要编造信息。
PROMPT;
public function __construct(
private ClientFactory $clientFactory,
private ConfigInterface $config
) {}
/**
* 流式调用 Claude,通过 callback 逐块返回文本
* @param callable $onChunk function(string $text): void
*/
public function streamChat(array $messages, callable $onChunk): string
{
$client = $this->clientFactory->create([
'timeout' => 120,
'connect_timeout' => 10,
]);
$response = $client->post('https://api.anthropic.com/v1/messages', [
'headers' => [
'x-api-key' => $this->config->get('ai.anthropic.api_key'),
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
],
'json' => [
'model' => $this->config->get('ai.anthropic.model'),
'max_tokens' => $this->config->get('ai.anthropic.max_tokens'),
'system' => self::SYSTEM_PROMPT,
'messages' => $messages,
'stream' => true,
],
'stream' => true,
]);
$fullText = '';
$body = $response->getBody();
while (!$body->eof()) {
$line = $this->readLine($body);
if (!str_starts_with($line, 'data: ')) continue;
$data = json_decode(substr($line, 6), true);
if (($data['type'] ?? '') !== 'content_block_delta') continue;
$chunk = $data['delta']['text'] ?? '';
if ($chunk === '') continue;
$fullText .= $chunk;
$onChunk($chunk);
}
return $fullText;
}
/**
* 非流式,直接返回完整回复(备用)
*/
public function chat(array $messages): string
{
$client = $this->clientFactory->create(['timeout' => 60]);
$response = $client->post('https://api.anthropic.com/v1/messages', [
'headers' => [
'x-api-key' => $this->config->get('ai.anthropic.api_key'),
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
],
'json' => [
'model' => $this->config->get('ai.anthropic.model'),
'max_tokens' => $this->config->get('ai.anthropic.max_tokens'),
'system' => self::SYSTEM_PROMPT,
'messages' => $messages,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
return $data['content'][0]['text'] ?? '';
}
private function readLine($body): string
{
$line = '';
while (!$body->eof()) {
$char = $body->read(1);
if ($char === "\n") break;
$line .= $char;
}
return rtrim($line, "\r");
}
}
---
4. 消息处理服务
app/Service/Bot/MessageHandler.php
<?php
namespace App\Service\Bot;
use App\Service\Feishu\MessageService;
use Psr\SimpleCache\CacheInterface;
class MessageHandler
{
public function __construct(
private ClaudeService $claude,
private ContextManager $context,
private MessageService $messageService,
private CacheInterface $cache
) {}
public function handle(array $event): void
{
$msgId = $event['message']['message_id'];
$chatId = $event['message']['chat_id'];
$openId = $event['sender']['sender_id']['open_id'];
$msgType = $event['message']['message_type'];
// 幂等:同一条消息只处理一次(飞书可能重推)
$dedupKey = 'feishu:bot:dedup:' . $msgId;
if ($this->cache->get($dedupKey)) return;
$this->cache->set($dedupKey, 1, 60);
// 只处理文本消息
if ($msgType !== 'text') {
$this->messageService->sendText($chatId, '暂时只支持文字消息', 'chat_id');
return;
}
$content = json_decode($event['message']['content'], true);
$userMsg = trim($content['text'] ?? '');
// 清空上下文指令
if (in_array($userMsg, ['/clear', '/重置', '/新对话'], true)) {
$this->context->clear($openId);
$this->messageService->sendText($chatId, '上下文已清空,开始新对话', 'chat_id');
return;
}
// 追加用户消息到上下文
$this->context->append($openId, 'user', $userMsg);
$messages = $this->context->get($openId);
// 先发一条「正在思考」占位(可选)
$this->messageService->sendText($chatId, '正在思考中...', 'chat_id');
// 调用 Claude(非流式,飞书不支持直接流式更新消息)
$reply = $this->claude->chat($messages);
// 追加 AI 回复到上下文
$this->context->append($openId, 'assistant', $reply);
// 回复用户(富文本支持 markdown)
$this->replyMarkdown($chatId, $reply);
}
/**
* 飞书不支持直接流式,用「分段发送」模拟流式体验
* 每积累一定字数发一次更新
*/
public function handleStream(array $event): void
{
$chatId = $event['message']['chat_id'];
$openId = $event['sender']['sender_id']['open_id'];
$content = json_decode($event['message']['content'], true);
$userMsg = trim($content['text'] ?? '');
$this->context->append($openId, 'user', $userMsg);
$messages = $this->context->get($openId);
$buffer = '';
$messageId = null;
$fullReply = '';
$this->claude->streamChat($messages, function (string $chunk) use (
&$buffer, &$messageId, &$fullReply, $chatId
) {
$buffer .= $chunk;
$fullReply .= $chunk;
// 每 50 字或遇到句号/换行就推送一次
if (mb_strlen($buffer) >= 50 || preg_match('/[。!?\n]/', $buffer)) {
if ($messageId === null) {
// 第一次:发新消息
$messageId = $this->messageService->sendText($chatId, $buffer, 'chat_id');
} else {
// 后续:更新同一条消息(追加内容)
// 注意:飞书更新消息是全量替换,需拼接完整内容
// 此处简化为继续发新消息分段展示
$this->messageService->sendText($chatId, $buffer, 'chat_id');
}
$buffer = '';
}
});
// 发送剩余内容
if ($buffer !== '') {
$this->messageService->sendText($chatId, $buffer, 'chat_id');
}
$this->context->append($openId, 'assistant', $fullReply);
}
private function replyMarkdown(string $chatId, string $text): void
{
// 长文本自动分段(飞书单条消息上限 30000 字)
$chunks = mb_str_split($text, 2000);
foreach ($chunks as $chunk) {
$this->messageService->sendText($chatId, $chunk, 'chat_id');
}
}
}
---
5. 事件接收 Controller
app/Controller/Feishu/BotController.php
<?php
namespace App\Controller\Feishu;
use App\Service\Bot\MessageHandler;
use App\Service\Feishu\EventVerifier;
use App\Job\BotMessageJob;
use Hyperf\AsyncQueue\Driver\DriverFactory;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\PostMapping;
use Hyperf\HttpServer\Contract\RequestInterface;
#[Controller(prefix: '/feishu/bot')]
class BotController
{
public function __construct(
private EventVerifier $verifier,
private DriverFactory $driverFactory
) {}
#[PostMapping(path: '/event')]
public function event(RequestInterface $request): array
{
$body = $request->getBody()->getContents();
$data = $this->verifier->verify($body);
// URL 验证握手
$challenge = $this->verifier->handleChallenge($data);
if ($challenge !== null) {
return ['challenge' => $challenge];
}
$eventType = $data['header']['event_type'] ?? '';
if ($eventType === 'im.message.receive_v1') {
// 投入异步队列,3 秒内先返回 200
$this->driverFactory->get('default')->push(
new BotMessageJob($data['event'])
);
}
return ['code' => 0];
}
}
---
6. 异步队列 Job
app/Job/BotMessageJob.php
<?php
namespace App\Job;
use App\Service\Bot\MessageHandler;
use Hyperf\AsyncQueue\Job;
class BotMessageJob extends Job
{
public int $maxAttempts = 1; // AI 调用不重试,避免重复回复
public function __construct(private array $event) {}
public function handle(): void
{
$handler = make(MessageHandler::class);
$handler->handle($this->event);
}
}
---
7. 飞书后台配置
开放平台 → 事件订阅:
✓ 接收消息 im.message.receive_v1
权限申请:
✓ im:message 读取消息
✓ im:message:send_as_bot 发送消息
回调地址:https://your-domain.com/feishu/bot/event
机器人设置 → 勾选「允许机器人接收消息」
---
关键点汇总
┌───────────────┬──────────────────────────────────────────────────────┐
│ 要点 │ 说明 │
├───────────────┼──────────────────────────────────────────────────────┤
│ 幂等去重 │ 飞书同一事件可能重推,用 message_id + Redis 60s 去重 │
├───────────────┼──────────────────────────────────────────────────────┤
│ 3 秒响应 │ Controller 立即返回,AI 调用全部走异步队列 │
├───────────────┼──────────────────────────────────────────────────────┤
│ 上下文 key │ 用 open_id 区分用户,群聊可改用 open_id + chat_id │
├───────────────┼──────────────────────────────────────────────────────┤
│ 流式限制 │ 飞书消息不支持 SSE,流式只能分段发多条或轮询更新 │
├───────────────┼──────────────────────────────────────────────────────┤
│ maxAttempts=1 │ AI Job 禁止重试,否则失败后重推会重复回复用户 │
├───────────────┼──────────────────────────────────────────────────────┤
│ 上下文裁剪 │ 超出 20 条丢弃最早一问一答,避免 token 超限 │
├───────────────┼──────────────────────────────────────────────────────┤
│ 群聊 @ 过滤 │ 群里需判断 mentions 是否包含机器人自身,私聊不需要 │
└───────────────┴──────────────────────────────────────────────────────┘
更多推荐



所有评论(0)