feat: 添加用户机器人 webhook 事件配置,优化相关逻辑

This commit is contained in:
kuaifan 2025-09-30 04:25:50 +00:00
parent 6ffd169784
commit f59bdaf5e0
8 changed files with 443 additions and 55 deletions

View File

@ -12,6 +12,7 @@ use App\Module\AI;
use App\Module\Doo;
use App\Models\File;
use App\Models\User;
use App\Models\UserBot;
use App\Module\Base;
use App\Module\Timer;
use App\Models\Setting;
@ -443,6 +444,29 @@ class DialogController extends AbstractController
return Base::retError('打开会话失败');
}
$data = WebSocketDialog::synthesizeData($dialog->id, $user->userid);
if ($userid > 0) {
$botTarget = User::whereUserid($userid)->whereBot(1)->first();
if ($botTarget) {
$userBot = UserBot::whereBotId($botTarget->userid)->first();
if ($userBot) {
$userBot->dispatchWebhook(UserBot::WEBHOOK_EVENT_DIALOG_OPEN, [
'dialog_id' => $dialog->id,
'dialog_type' => $dialog->type,
'session_id' => $dialog->session_id,
'dialog_name' => $dialog->getGroupName(),
'user' => [
'userid' => $user->userid,
'email' => $user->email,
'nickname' => $user->nickname,
],
], 10, [
'dialog' => $dialog->id,
'operator' => $user->userid,
]);
}
}
}
return Base::retSuccess('success', $data);
}

View File

@ -2150,7 +2150,8 @@ class UsersController extends AbstractController
'users.nickname',
'users.userimg',
'user_bots.clear_day',
'user_bots.webhook_url'
'user_bots.webhook_url',
'user_bots.webhook_events'
])
->orderByDesc('id')
->get()
@ -2160,6 +2161,7 @@ class UsersController extends AbstractController
$bot['name'] = $bot['nickname'];
$bot['avatar'] = $bot['userimg'];
$bot['system_name'] = UserBot::systemBotName($bot['name']);
$bot['webhook_events'] = UserBot::normalizeWebhookEvents($bot['webhook_events'] ?? null, empty($bot['webhook_events']));
unset($bot['userid'], $bot['nickname'], $bot['userimg']);
}
@ -2211,11 +2213,13 @@ class UsersController extends AbstractController
'avatar' => $botUser->userimg,
'clear_day' => 0,
'webhook_url' => '',
'webhook_events' => [UserBot::WEBHOOK_EVENT_MESSAGE],
'system_name' => UserBot::systemBotName($botUser->email),
];
if ($userBot) {
$data['clear_day'] = $userBot->clear_day;
$data['webhook_url'] = $userBot->webhook_url;
$data['webhook_events'] = $userBot->webhook_events;
}
return Base::retSuccess('success', $data);
}
@ -2296,6 +2300,9 @@ class UsersController extends AbstractController
if (Arr::exists($data, 'webhook_url')) {
$upBot['webhook_url'] = trim($data['webhook_url']);
}
if (Arr::exists($data, 'webhook_events')) {
$upBot['webhook_events'] = UserBot::normalizeWebhookEvents($data['webhook_events'], false);
}
//
if ($upUser) {
$botUser->updateInstance($upUser);
@ -2312,11 +2319,13 @@ class UsersController extends AbstractController
'avatar' => $botUser->userimg,
'clear_day' => 0,
'webhook_url' => '',
'webhook_events' => [UserBot::WEBHOOK_EVENT_MESSAGE],
'system_name' => UserBot::systemBotName($botUser->email),
];
if ($userBot) {
$data['clear_day'] = $userBot->clear_day;
$data['webhook_url'] = $userBot->webhook_url;
$data['webhook_events'] = $userBot->webhook_events;
}
return Base::retSuccess($botId ? '修改成功' : '添加成功', $data);
}

View File

@ -5,10 +5,12 @@ namespace App\Models;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Extranet;
use App\Module\Ihttp;
use App\Module\Timer;
use App\Tasks\JokeSoupTask;
use Cache;
use Carbon\Carbon;
use Throwable;
/**
* App\Models\UserBot
@ -20,6 +22,7 @@ use Carbon\Carbon;
* @property \Illuminate\Support\Carbon|null $clear_at 下一次清理时间
* @property string|null $webhook_url 消息webhook地址
* @property int|null $webhook_num 消息webhook请求次数
* @property array|null $webhook_events Webhook事件配置
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
@ -44,6 +47,131 @@ use Carbon\Carbon;
*/
class UserBot extends AbstractModel
{
public const WEBHOOK_EVENT_MESSAGE = 'message';
public const WEBHOOK_EVENT_DIALOG_OPEN = 'dialog_open';
public const WEBHOOK_EVENT_MEMBER_JOIN = 'member_join';
public const WEBHOOK_EVENT_MEMBER_LEAVE = 'member_leave';
protected $casts = [
'webhook_events' => 'array',
];
/**
* 获取可选的 webhook 事件
*
* @return string[]
*/
public static function webhookEventOptions(): array
{
return [
self::WEBHOOK_EVENT_MESSAGE,
self::WEBHOOK_EVENT_DIALOG_OPEN,
self::WEBHOOK_EVENT_MEMBER_JOIN,
self::WEBHOOK_EVENT_MEMBER_LEAVE,
];
}
/**
* 标准化 webhook 事件配置
*
* @param mixed $events
* @return array
*/
public static function normalizeWebhookEvents(mixed $events, bool $useFallback = true): array
{
if (is_string($events)) {
$events = Base::json2array($events);
}
if ($events === null) {
$events = [];
}
if (!is_array($events)) {
$events = [$events];
}
$events = array_filter(array_map('strval', $events));
$events = array_values(array_intersect($events, self::webhookEventOptions()));
return $events ?: ($useFallback ? [self::WEBHOOK_EVENT_MESSAGE] : []);
}
/**
* 获取 webhook 事件配置
*
* @param mixed $value
* @return array
*/
public function getWebhookEventsAttribute(mixed $value): array
{
if ($value === null || $value === '') {
return self::normalizeWebhookEvents(null, true);
}
return self::normalizeWebhookEvents($value, false);
}
/**
* 设置 webhook 事件配置
*
* @param mixed $value
* @return void
*/
public function setWebhookEventsAttribute(mixed $value): void
{
$useFallback = $value === null;
$this->attributes['webhook_events'] = Base::array2json(self::normalizeWebhookEvents($value, $useFallback));
}
/**
* 判断是否需要触发指定 webhook 事件
*
* @param string $event
* @return bool
*/
public function shouldDispatchWebhook(string $event): bool
{
if (!$this->webhook_url) {
return false;
}
if (!preg_match('/^https?:\/\//', $this->webhook_url)) {
return false;
}
return in_array($event, $this->webhook_events ?? [], true);
}
/**
* 发送 webhook
*
* @param string $event
* @param array $payload
* @param int $timeout
* @param array $context
* @return array|null
*/
public function dispatchWebhook(string $event, array $payload, int $timeout = 30, array $context = []): ?array
{
if (!$this->shouldDispatchWebhook($event)) {
return null;
}
$payload = array_merge([
'event' => $event,
'timestamp' => time(),
'bot_uid' => $this->bot_id,
'owner_uid' => $this->userid,
], $payload);
try {
$result = Ihttp::ihttp_post($this->webhook_url, $payload, $timeout);
$this->increment('webhook_num');
return $result;
} catch (Throwable $th) {
info(Base::array2json(array_merge($context, [
'bot_userid' => $this->bot_id,
'event' => $event,
'webhook_url' => $this->webhook_url,
'error' => $th->getMessage(),
])));
return null;
}
}
/**
* 判断是否系统机器人

View File

@ -461,7 +461,8 @@ class WebSocketDialog extends AbstractModel
*/
public function joinGroup($userid, $inviter, $important = null)
{
AbstractModel::transaction(function () use ($important, $inviter, $userid) {
$addedUserIds = [];
AbstractModel::transaction(function () use ($important, $inviter, $userid, &$addedUserIds) {
foreach (is_array($userid) ? $userid : [$userid] as $value) {
if ($value > 0) {
$updateData = [
@ -480,6 +481,7 @@ class WebSocketDialog extends AbstractModel
]);
}, $isInsert);
if ($isInsert) {
$addedUserIds[] = $value;
WebSocketDialogMsg::sendMsg(null, $this->id, 'notice', [
'notice' => User::userid2nickname($value) . " 已加入群组"
], $inviter, true, true);
@ -490,6 +492,16 @@ class WebSocketDialog extends AbstractModel
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
if ($addedUserIds) {
$meta = ['action' => 'join'];
if ($inviter > 0) {
$actor = $this->getUserSnapshots([$inviter]);
if (!empty($actor)) {
$meta['actor'] = $actor[0];
}
}
$this->dispatchMemberWebhook(UserBot::WEBHOOK_EVENT_MEMBER_JOIN, $addedUserIds, $meta);
}
return true;
}
@ -503,14 +515,15 @@ class WebSocketDialog extends AbstractModel
public function exitGroup($userid, $type = 'exit', $checkDelete = true, $pushMsg = true)
{
$typeDesc = $type === 'remove' ? '移出' : '退出';
AbstractModel::transaction(function () use ($pushMsg, $checkDelete, $typeDesc, $type, $userid) {
$removedUserIds = [];
AbstractModel::transaction(function () use ($pushMsg, $checkDelete, $typeDesc, $type, $userid, &$removedUserIds) {
$builder = WebSocketDialogUser::whereDialogId($this->id);
if (is_array($userid)) {
$builder->whereIn('userid', $userid);
} else {
$builder->whereUserid($userid);
}
$builder->chunkById(100, function($list) use ($pushMsg, $checkDelete, $typeDesc, $type) {
$builder->chunkById(100, function($list) use ($pushMsg, $checkDelete, $typeDesc, $type, &$removedUserIds) {
/** @var WebSocketDialogUser $item */
foreach ($list as $item) {
if ($checkDelete) {
@ -531,6 +544,7 @@ class WebSocketDialog extends AbstractModel
}
//
$item->delete();
$removedUserIds[] = $item->userid;
//
if ($pushMsg) {
if ($type === 'remove') {
@ -549,6 +563,87 @@ class WebSocketDialog extends AbstractModel
$data = WebSocketDialog::generatePeople($this->id);
$data['id'] = $this->id;
$this->pushMsg("groupUpdate", $data);
if ($removedUserIds) {
$meta = ['action' => $type];
$operatorId = User::userid();
if ($operatorId > 0) {
$actor = $this->getUserSnapshots([$operatorId]);
if (!empty($actor)) {
$meta['actor'] = $actor[0];
}
}
$this->dispatchMemberWebhook(UserBot::WEBHOOK_EVENT_MEMBER_LEAVE, $removedUserIds, $meta);
}
}
/**
* 获取用户快照
* @param array $userIds
* @return array
*/
protected function getUserSnapshots(array $userIds): array
{
$userIds = array_values(array_unique(array_filter($userIds)));
if (empty($userIds)) {
return [];
}
return User::whereIn('userid', $userIds)
->get(['userid', 'nickname', 'email', 'bot'])
->map(function (User $user) {
return [
'userid' => $user->userid,
'nickname' => $user->nickname,
'email' => $user->email,
'is_bot' => (bool)$user->bot,
];
})
->values()
->all();
}
/**
* 推送成员事件到机器人 webhook
* @param string $event
* @param array $memberIds
* @param array $meta
* @return void
*/
protected function dispatchMemberWebhook(string $event, array $memberIds, array $meta = []): void
{
$memberIds = array_values(array_unique(array_filter($memberIds)));
if (empty($memberIds)) {
return;
}
$botIds = $this->dialogUser()->where('bot', 1)->pluck('userid')->toArray();
if (empty($botIds)) {
return;
}
$userBots = UserBot::whereIn('bot_id', $botIds)->get();
if ($userBots->isEmpty()) {
return;
}
$members = $this->getUserSnapshots($memberIds);
if (empty($members)) {
return;
}
$payload = array_merge([
'dialog_id' => $this->id,
'dialog_type' => $this->type,
'group_type' => $this->group_type,
'dialog_name' => $this->getGroupName(),
'members' => $members,
], array_filter($meta, fn ($value) => $value !== null));
foreach ($userBots as $userBot) {
$userBot->dispatchWebhook($event, $payload, 10, [
'dialog' => $this->id,
'event_members' => $memberIds,
]);
}
}
/**

View File

@ -427,6 +427,7 @@ class BotReceiveMsgTask extends AbstractTask
private function handleWebhookRequest($sendText, $replyText, WebSocketDialogMsg $msg, WebSocketDialog $dialog, User $botUser)
{
$webhookUrl = null;
$userBot = null;
$extras = ['timestamp' => time()];
try {
@ -530,13 +531,11 @@ class BotReceiveMsgTask extends AbstractTask
return;
}
$userBot = UserBot::whereBotId($botUser->userid)->first();
if ($userBot) {
$userBot->webhook_num++;
$userBot->save();
$webhookUrl = $userBot->webhook_url;
if (!$userBot || !$userBot->shouldDispatchWebhook(UserBot::WEBHOOK_EVENT_MESSAGE)) {
return;
}
}
if (!preg_match("/^https?:\/\//", $webhookUrl)) {
if (!$userBot && !preg_match("/^https?:\/\//", $webhookUrl)) {
return;
}
} catch (\Exception $e) {
@ -547,7 +546,6 @@ class BotReceiveMsgTask extends AbstractTask
return;
}
//
try {
$data = [
'text' => $sendText,
'reply_text' => $replyText,
@ -574,15 +572,16 @@ class BotReceiveMsgTask extends AbstractTask
'token' => User::generateTokenNoDevice($userInfo, now()->addHour()),
];
}
// 请求Webhook
$result = null;
if ($userBot) {
$result = $userBot->dispatchWebhook(UserBot::WEBHOOK_EVENT_MESSAGE, $data, 30, [
'dialog' => $dialog->id,
'msg' => $msg->id,
]);
} else {
try {
$result = Ihttp::ihttp_post($webhookUrl, $data, 30);
if ($result['data'] && $data = Base::json2array($result['data'])) {
if ($data['code'] != 200 && $data['message']) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'text', [
'text' => $result['data']['message']
], $botUser->userid, false, false, true);
}
}
} catch (\Throwable $th) {
info(Base::array2json([
'bot_userid' => $botUser->userid,
@ -594,6 +593,16 @@ class BotReceiveMsgTask extends AbstractTask
}
}
if ($result && isset($result['data'])) {
$responseData = Base::json2array($result['data']);
if (($responseData['code'] ?? 0) != 200 && !empty($responseData['message'])) {
WebSocketDialogMsg::sendMsg(null, $msg->dialog_id, 'text', [
'text' => $responseData['message']
], $botUser->userid, false, false, true);
}
}
}
/**
* 为AI机器人转换提及消息格式
* 将提及的任务、文件、报告转换为AI可理解的格式并提取相关内容

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\DB;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_bots', function (Blueprint $table) {
if (!Schema::hasColumn('user_bots', 'webhook_events')) {
$table->text('webhook_events')->nullable()->after('webhook_num')->comment('Webhook事件配置');
}
});
DB::table('user_bots')
->where(function ($query) {
$query->whereNull('webhook_events')->orWhere('webhook_events', '');
})
->update(['webhook_events' => json_encode(['message'])]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('user_bots', function (Blueprint $table) {
if (Schema::hasColumn('user_bots', 'webhook_events')) {
$table->dropColumn('webhook_events');
}
});
}
};

View File

@ -106,6 +106,7 @@
<p><span>ID:</span>{{ item.id }}</p>
<p><span>{{ $L('清理时间') }}:</span>{{ item.clear_day }}</p>
<p><span>Webhook:</span>{{ item.webhook_url || '-' }}</p>
<p><span>{{ $L('Webhook事件') }}:</span>{{ formatWebhookEvents(item.webhook_events) }}</p>
</div>
<div class="modal-item-btns">
<Button icon="md-chatbubbles" @click="applyClick({value: 'mybot-chat'}, item)">{{ $L('开始聊天') }}</Button>
@ -140,6 +141,13 @@
<FormItem prop="webhook_url" label="Webhook">
<Input v-model="mybotModifyData.webhook_url" :maxlength="255" :show-word-limit="0.9" type="textarea" placeholder="Webhook"/>
</FormItem>
<FormItem prop="webhook_events" :label="$L('Webhook事件')">
<CheckboxGroup v-model="mybotModifyData.webhook_events">
<Checkbox v-for="option in webhookEventOptions" :key="option.value" :label="option.value">
{{ $L(option.label) }}
</Checkbox>
</CheckboxGroup>
</FormItem>
</Form>
<div slot="footer" class="adaption">
<Button type="default" @click="mybotModifyShow=false">{{ $L('取消') }}</Button>
@ -363,6 +371,12 @@ export default {
mybotModifyShow: false,
mybotModifyData: {},
mybotModifyLoad: 0,
webhookEventOptions: [
{value: 'message', label: '接收消息'},
{value: 'dialog_open', label: '打开会话'},
{value: 'member_join', label: '成员加入'},
{value: 'member_leave', label: '成员退出'},
],
//
aibotShow: false,
aibotList: AIBotList,
@ -456,6 +470,37 @@ export default {
}
},
methods: {
normalizeWebhookEvents(events = [], useFallback = false) {
if (!Array.isArray(events)) {
events = events ? [events] : [];
}
const allowed = this.webhookEventOptions.map(item => item.value);
const result = events.filter(item => allowed.includes(item));
if (result.length) {
return Array.from(new Set(result));
}
return useFallback ? ['message'] : [];
},
enhanceMybotItem(item = {}) {
const data = $A.cloneJSON(item || {});
let events = data.webhook_events;
if (typeof events === 'undefined' || events === null) {
events = ['message'];
}
events = this.normalizeWebhookEvents(events, false);
if (!events.length) {
events = [];
}
data.webhook_events = events;
return data;
},
formatWebhookEvents(events) {
const values = this.normalizeWebhookEvents(events, false);
const labels = this.webhookEventOptions
.filter(option => values.includes(option.value))
.map(option => this.$L(option.label));
return labels.length ? labels.join('、') : '-';
},
getLogoClass(name) {
name = name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
return name
@ -548,7 +593,7 @@ export default {
this.$store.dispatch("call", {
url: 'users/bot/list',
}).then(({data}) => {
this.mybotList = data.list;
this.mybotList = (data.list || []).map(item => this.enhanceMybotItem(item));
}).finally(_ => {
this.mybotLoad--
});
@ -561,7 +606,7 @@ export default {
},
//
addMybot(info) {
this.mybotModifyData = $A.cloneJSON(info)
this.mybotModifyData = this.enhanceMybotItem(info)
this.mybotModifyShow = true;
},
//
@ -600,11 +645,12 @@ export default {
onMybotModify() {
this.mybotModifyLoad++
this.$store.dispatch("editUserBot", this.mybotModifyData).then(({data, msg}) => {
const index = this.mybotList.findIndex(item => item.id === data.id);
const botData = this.enhanceMybotItem(data);
const index = this.mybotList.findIndex(item => item.id === botData.id);
if (index > -1) {
this.mybotList.splice(index, 1, data);
this.mybotList.splice(index, 1, botData);
} else {
this.mybotList.unshift(data);
this.mybotList.unshift(botData);
}
this.mybotModifyShow = false;
this.mybotModifyData = {};

View File

@ -462,6 +462,13 @@
<FormItem v-if="typeof modifyData.webhook_url !== 'undefined'" prop="webhook_url" label="Webhook">
<Input v-model="modifyData.webhook_url" :maxlength="255" />
</FormItem>
<FormItem v-if="typeof modifyData.webhook_events !== 'undefined'" prop="webhook_events" :label="$L('Webhook事件')">
<CheckboxGroup v-model="modifyData.webhook_events">
<Checkbox v-for="option in webhookEventOptions" :key="option.value" :label="option.value">
{{$L(option.label)}}
</Checkbox>
</CheckboxGroup>
</FormItem>
</template>
</Form>
<div slot="footer" class="adaption">
@ -768,6 +775,12 @@ export default {
modifyAiShow: false,
modifyData: {},
modifyLoad: 0,
webhookEventOptions: [
{value: 'message', label: '接收消息'},
{value: 'dialog_open', label: '打开会话'},
{value: 'member_join', label: '成员加入'},
{value: 'member_leave', label: '成员退出'},
],
openId: 0,
errorId: 0,
@ -1449,6 +1462,28 @@ export default {
methods: {
transformEmojiToHtml,
normalizeWebhookEvents(events = [], useFallback = false) {
if (!Array.isArray(events)) {
events = events ? [events] : [];
}
const allowed = this.webhookEventOptions.map(item => item.value);
const result = events.filter(item => allowed.includes(item));
if (result.length) {
return Array.from(new Set(result));
}
return useFallback ? ['message'] : [];
},
prepareWebhookEvents(events, useFallback = false) {
let value = events;
if (typeof value === 'undefined' || value === null) {
value = useFallback ? ['message'] : [];
}
value = this.normalizeWebhookEvents(value, false);
if (!value.length && useFallback) {
return ['message'];
}
return value;
},
/**
* 获取会话基本信息
* @param dialog_id
@ -2727,6 +2762,7 @@ export default {
clear_day: 0,
webhook_url: '',
system_name: '',
webhook_events: this.prepareWebhookEvents([], true),
})
this.modifyLoad++;
this.$store.dispatch("call", {
@ -2738,6 +2774,7 @@ export default {
this.modifyData.clear_day = data.clear_day
this.modifyData.webhook_url = data.webhook_url
this.modifyData.system_name = data.system_name
this.modifyData.webhook_events = this.prepareWebhookEvents(data.webhook_events, true)
}).finally(() => {
this.modifyLoad--;
})
@ -2892,6 +2929,7 @@ export default {
name: this.modifyData.name,
clear_day: this.modifyData.clear_day,
webhook_url: this.modifyData.webhook_url,
webhook_events: this.normalizeWebhookEvents(this.modifyData.webhook_events, false),
dialog_id: this.modifyData.dialog_id
}).then(({msg}) => {
$A.messageSuccess(msg);