feat: channel webhooks

This commit is contained in:
Timothy Jaeryang Baek
2026-01-09 02:30:15 +04:00
parent 48bdb3f266
commit cd296fcf0d
10 changed files with 1012 additions and 12 deletions

View File

@@ -1,4 +1,5 @@
import json
import secrets
import time
import uuid
from typing import Optional
@@ -245,6 +246,11 @@ class CreateChannelForm(ChannelForm):
type: Optional[str] = None
class ChannelWebhookForm(BaseModel):
name: str
profile_image_url: Optional[str] = None
class ChannelTable:
def _collect_unique_user_ids(
@@ -945,5 +951,115 @@ class ChannelTable:
db.commit()
return True
####################
# Webhook Methods
####################
def insert_webhook(
self,
channel_id: str,
user_id: str,
form_data: ChannelWebhookForm,
db: Optional[Session] = None,
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = ChannelWebhookModel(
id=str(uuid.uuid4()),
channel_id=channel_id,
user_id=user_id,
name=form_data.name,
profile_image_url=form_data.profile_image_url,
token=secrets.token_urlsafe(32),
last_used_at=None,
created_at=int(time.time_ns()),
updated_at=int(time.time_ns()),
)
db.add(ChannelWebhook(**webhook.model_dump()))
db.commit()
return webhook
def get_webhooks_by_channel_id(
self, channel_id: str, db: Optional[Session] = None
) -> list[ChannelWebhookModel]:
with get_db_context(db) as db:
webhooks = (
db.query(ChannelWebhook)
.filter(ChannelWebhook.channel_id == channel_id)
.all()
)
return [ChannelWebhookModel.model_validate(w) for w in webhooks]
def get_webhook_by_id(
self, webhook_id: str, db: Optional[Session] = None
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook)
.filter(ChannelWebhook.id == webhook_id)
.first()
)
return ChannelWebhookModel.model_validate(webhook) if webhook else None
def get_webhook_by_id_and_token(
self, webhook_id: str, token: str, db: Optional[Session] = None
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook)
.filter(
ChannelWebhook.id == webhook_id,
ChannelWebhook.token == token,
)
.first()
)
return ChannelWebhookModel.model_validate(webhook) if webhook else None
def update_webhook_by_id(
self,
webhook_id: str,
form_data: ChannelWebhookForm,
db: Optional[Session] = None,
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook)
.filter(ChannelWebhook.id == webhook_id)
.first()
)
if not webhook:
return None
webhook.name = form_data.name
webhook.profile_image_url = form_data.profile_image_url
webhook.updated_at = int(time.time_ns())
db.commit()
return ChannelWebhookModel.model_validate(webhook)
def update_webhook_last_used_at(
self, webhook_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook)
.filter(ChannelWebhook.id == webhook_id)
.first()
)
if not webhook:
return False
webhook.last_used_at = int(time.time_ns())
db.commit()
return True
def delete_webhook_by_id(
self, webhook_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
result = (
db.query(ChannelWebhook)
.filter(ChannelWebhook.id == webhook_id)
.delete()
)
db.commit()
return result > 0
Channels = ChannelTable()

View File

@@ -199,11 +199,32 @@ class MessageTable:
if include_thread_replies:
thread_replies = self.get_thread_replies_by_message_id(id, db=db)
user = Users.get_user_by_id(message.user_id, db=db)
# Check if message was sent by webhook (webhook info in meta takes precedence)
webhook_info = message.meta.get("webhook") if message.meta else None
if webhook_info and webhook_info.get("id"):
# Look up webhook by ID to get current name
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
# Webhook was deleted, use placeholder
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
else:
user = Users.get_user_by_id(message.user_id, db=db)
user_info = user.model_dump() if user else None
return MessageResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user.model_dump() if user else None,
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump() if reply_to_message else None
),
@@ -235,10 +256,29 @@ class MessageTable:
if message.reply_to_id
else None
)
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
@@ -284,10 +324,29 @@ class MessageTable:
if message.reply_to_id
else None
)
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
@@ -334,10 +393,29 @@ class MessageTable:
if message.reply_to_id
else None
)
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message

View File

@@ -1,9 +1,12 @@
import json
import logging
import base64
import io
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
from fastapi.responses import Response, StreamingResponse, FileResponse
from pydantic import BaseModel
from pydantic import field_validator
@@ -29,6 +32,8 @@ from open_webui.models.channels import (
ChannelForm,
ChannelResponse,
CreateChannelForm,
ChannelWebhookModel,
ChannelWebhookForm,
)
from open_webui.models.messages import (
Messages,
@@ -43,6 +48,7 @@ from open_webui.utils.files import get_image_base64_from_file_id
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import STATIC_DIR
from open_webui.utils.models import (
@@ -822,6 +828,11 @@ async def get_channel_messages(
thread_replies[0].created_at if thread_replies else None
)
# Use message.user if present (for webhooks), otherwise look up by user_id
user_info = message.user
if user_info is None and message.user_id in users:
user_info = UserNameResponse(**users[message.user_id].model_dump())
messages.append(
MessageUserResponse(
**{
@@ -831,7 +842,7 @@ async def get_channel_messages(
"reactions": Messages.get_reactions_by_message_id(
message.id, db=db
),
"user": UserNameResponse(**users[message.user_id].model_dump()),
"user": user_info,
}
)
)
@@ -889,6 +900,19 @@ async def get_pinned_channel_messages(
messages = []
for message in message_list:
# Check for webhook identity in meta
webhook_info = message.meta.get("webhook") if message.meta else None
if webhook_info:
user_info = UserNameResponse(
id=webhook_info.get("id"),
name=webhook_info.get("name"),
role="webhook",
)
elif message.user_id in users:
user_info = UserNameResponse(**users[message.user_id].model_dump())
else:
user_info = None
messages.append(
MessageWithReactionsResponse(
**{
@@ -896,7 +920,7 @@ async def get_pinned_channel_messages(
"reactions": Messages.get_reactions_by_message_id(
message.id, db=db
),
"user": UserNameResponse(**users[message.user_id].model_dump()),
"user": user_info,
}
)
)
@@ -1476,6 +1500,11 @@ async def get_channel_thread_messages(
messages = []
for message in message_list:
# Use message.user if present (for webhooks), otherwise look up by user_id
user_info = message.user
if user_info is None and message.user_id in users:
user_info = UserNameResponse(**users[message.user_id].model_dump())
messages.append(
MessageUserResponse(
**{
@@ -1485,7 +1514,7 @@ async def get_channel_thread_messages(
"reactions": Messages.get_reactions_by_message_id(
message.id, db=db
),
"user": UserNameResponse(**users[message.user_id].model_dump()),
"user": user_info,
}
)
)
@@ -1835,3 +1864,262 @@ async def delete_message_by_id(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# Webhooks
############################
@router.get("/webhooks/{webhook_id}/profile/image")
async def get_webhook_profile_image(
webhook_id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""Get webhook profile image by webhook ID."""
webhook = Channels.get_webhook_by_id(webhook_id, db=db)
if not webhook:
# Return default favicon if webhook not found
return FileResponse(f"{STATIC_DIR}/favicon.png")
if webhook.profile_image_url:
# Check if it's url or base64
if webhook.profile_image_url.startswith("http"):
return Response(
status_code=status.HTTP_302_FOUND,
headers={"Location": webhook.profile_image_url},
)
elif webhook.profile_image_url.startswith("data:image"):
try:
header, base64_data = webhook.profile_image_url.split(",", 1)
image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data)
media_type = header.split(";")[0].lstrip("data:")
return StreamingResponse(
image_buffer,
media_type=media_type,
headers={"Content-Disposition": "inline"},
)
except Exception as e:
pass
# Return default favicon if no profile image
return FileResponse(f"{STATIC_DIR}/favicon.png")
@router.get("/{id}/webhooks", response_model=list[ChannelWebhookModel])
async def get_channel_webhooks(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
# Only channel managers can view webhooks
if (
not Channels.is_user_channel_manager(channel.id, user.id, db=db)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED
)
return Channels.get_webhooks_by_channel_id(id, db=db)
@router.post("/{id}/webhooks/create", response_model=ChannelWebhookModel)
async def create_channel_webhook(
request: Request,
id: str,
form_data: ChannelWebhookForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
# Only channel managers can create webhooks
if (
not Channels.is_user_channel_manager(channel.id, user.id, db=db)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED
)
webhook = Channels.insert_webhook(id, user.id, form_data, db=db)
if not webhook:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
return webhook
@router.post("/{id}/webhooks/{webhook_id}/update", response_model=ChannelWebhookModel)
async def update_channel_webhook(
request: Request,
id: str,
webhook_id: str,
form_data: ChannelWebhookForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
# Only channel managers can update webhooks
if (
not Channels.is_user_channel_manager(channel.id, user.id, db=db)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED
)
webhook = Channels.get_webhook_by_id(webhook_id, db=db)
if not webhook or webhook.channel_id != id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
updated = Channels.update_webhook_by_id(webhook_id, form_data, db=db)
if not updated:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
return updated
@router.delete("/{id}/webhooks/{webhook_id}/delete", response_model=bool)
async def delete_channel_webhook(
request: Request,
id: str,
webhook_id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
# Only channel managers can delete webhooks
if (
not Channels.is_user_channel_manager(channel.id, user.id, db=db)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED
)
webhook = Channels.get_webhook_by_id(webhook_id, db=db)
if not webhook or webhook.channel_id != id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
return Channels.delete_webhook_by_id(webhook_id, db=db)
############################
# Public Webhook Endpoint
############################
class WebhookMessageForm(BaseModel):
content: str
@router.post("/webhooks/{webhook_id}/{token}")
async def post_webhook_message(
request: Request,
webhook_id: str,
token: str,
form_data: WebhookMessageForm,
db: Session = Depends(get_session),
):
"""Public endpoint to post messages via webhook. No authentication required."""
check_channels_access(request)
# Validate webhook
webhook = Channels.get_webhook_by_id_and_token(webhook_id, token, db=db)
if not webhook:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid webhook URL",
)
channel = Channels.get_channel_by_id(webhook.channel_id, db=db)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
# Create message with webhook identity stored in meta
message = Messages.insert_new_message(
MessageForm(content=form_data.content, meta={"webhook": {"id": webhook.id}}),
webhook.channel_id,
webhook.user_id, # Required for DB but webhook info in meta takes precedence
db=db,
)
if not message:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to create message",
)
# Update last_used_at
Channels.update_webhook_last_used_at(webhook_id, db=db)
# Get full message and emit event
message = Messages.get_message_by_id(message.id, db=db)
event_data = {
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message",
"data": {
**message.model_dump(),
"user": {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
},
},
},
"user": {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
},
"channel": channel.model_dump(),
}
await sio.emit(
"events:channel",
event_data,
to=f"channel:{channel.id}",
)
return {"success": True, "message_id": message.id}

View File

@@ -763,3 +763,155 @@ export const deleteMessage = async (token: string = '', channel_id: string, mess
return res;
};
// Webhook API functions
type WebhookForm = {
name: string;
profile_image_url?: string;
};
export const getChannelWebhooks = async (token: string = '', channel_id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createChannelWebhook = async (
token: string = '',
channel_id: string,
formData: WebhookForm
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...formData })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateChannelWebhook = async (
token: string = '',
channel_id: string,
webhook_id: string,
formData: WebhookForm
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/${webhook_id}/update`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...formData })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteChannelWebhook = async (
token: string = '',
channel_id: string,
webhook_id: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/${webhook_id}/delete`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View File

@@ -119,8 +119,8 @@
if (type === 'message') {
if ((data?.parent_id ?? null) === null) {
const tempId = data?.temp_id ?? null;
messages = [{ ...data, temp_id: null }, ...messages.filter((m) => m?.temp_id !== tempId)];
const tempId = data?.temp_id ?? null;
messages = [{ ...data, temp_id: null }, ...messages.filter((m) => !tempId || m?.temp_id !== tempId)];
if (typingUsers.find((user) => user.id === event.user.id)) {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);

View File

@@ -131,8 +131,9 @@
replyToMessage={replyToMessage?.id === message.id}
disabled={!channel?.write_access || message?.temp_id}
pending={!!message?.temp_id}
showUserProfile={messageIdx === 0 ||
showUserProfile={messageIdx === 0 ||
messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
messageList.at(messageIdx - 1)?.user?.id !== message.user?.id ||
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id ||
message?.reply_to_message !== null}
onDelete={() => {

View File

@@ -245,7 +245,7 @@
/>
{:else}
<img
src={`${WEBUI_API_BASE_URL}/users/${message.reply_to_message.user?.id}/profile/image`}
src={message.reply_to_message.user?.role === 'webhook' ? `${WEBUI_API_BASE_URL}/channels/webhooks/${message.reply_to_message.user?.id}/profile/image` : `${WEBUI_API_BASE_URL}/users/${message.reply_to_message.user?.id}/profile/image`}
alt={message.reply_to_message.user?.name ?? $i18n.t('Unknown User')}
class="size-4 ml-0.5 rounded-full object-cover"
/>
@@ -277,10 +277,10 @@
alt={message.meta.model_name ?? message.meta.model_id}
class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
/>
{:else}
{:else}
<ProfilePreview user={message.user}>
<ProfileImage
src={`${WEBUI_API_BASE_URL}/users/${message.user.id}/profile/image`}
src={message.user?.role === 'webhook' ? `${WEBUI_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image` : `${WEBUI_API_BASE_URL}/users/${message.user?.id}/profile/image`}
className={'size-8 ml-0.5'}
/>
</ProfilePreview>

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Clipboard from '$lib/components/icons/Clipboard.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { toast } from 'svelte-sonner';
import dayjs from 'dayjs';
export let webhook;
export let expanded = false;
export let onClick = () => {};
export let onDelete = () => {};
export let onUpdate = (changes: { name: string; profile_image_url: string }) => {};
let name = webhook.name;
let image = webhook.profile_image_url || '';
// Notify parent when changes occur
$: if (name !== webhook.name || image !== (webhook.profile_image_url || '')) {
onUpdate({ name: name.trim() || webhook.name, profile_image_url: image });
}
let filesInputElement;
let inputFiles;
const handleImageUpload = () => {
if (!inputFiles?.length) return;
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = `${event.target?.result}`;
const fileType = inputFiles[0]?.type;
if (['image/gif', 'image/webp'].includes(fileType)) {
image = dataUrl;
} else {
const tempImage = new Image();
tempImage.src = dataUrl;
tempImage.onload = () => {
const canvas = document.createElement('canvas');
const canvasSize = 100;
canvas.width = canvasSize;
canvas.height = canvasSize;
const context = canvas.getContext('2d');
const aspectRatio = tempImage.width / tempImage.height;
const scaledWidth = aspectRatio > 1 ? canvasSize * aspectRatio : canvasSize;
const scaledHeight = aspectRatio > 1 ? canvasSize : canvasSize / aspectRatio;
const offsetX = (canvasSize - scaledWidth) / 2;
const offsetY = (canvasSize - scaledHeight) / 2;
context.drawImage(tempImage, offsetX, offsetY, scaledWidth, scaledHeight);
image = canvas.toDataURL('image/webp', 0.8);
};
}
inputFiles = null;
};
reader.readAsDataURL(inputFiles[0]);
};
const copyUrl = () => {
navigator.clipboard.writeText(
`${WEBUI_API_BASE_URL}/channels/webhooks/${webhook.id}/${webhook.token}`
);
toast.success($i18n.t('Copied'));
};
</script>
<input
bind:this={filesInputElement}
bind:files={inputFiles}
type="file"
hidden
accept="image/*"
on:change={handleImageUpload}
/>
<div class="text-xs -mx-1">
<!-- Row -->
<button
type="button"
class="w-full flex items-center gap-3 px-3.5 py-3 hover:bg-gray-50 dark:hover:bg-gray-900 rounded-xl transition"
on:click={onClick}
>
<img
src={image || `${WEBUI_BASE_URL}/static/favicon.png`}
class="rounded-full size-8 object-cover flex-shrink-0"
alt=""
/>
<div class="flex-1 text-left min-w-0">
<div class="font-medium text-gray-900 dark:text-white truncate">
{name}
</div>
<div class="text-gray-500 text-xs">
{$i18n.t('Created on {{date}}', {
date: dayjs(webhook.created_at / 1000000).format('MMM D, YYYY')
})}
{#if webhook.user?.name}
{$i18n.t('by {{name}}', { name: webhook.user.name })}
{/if}
</div>
</div>
<ChevronDown
className="size-3.5 text-gray-400 transition-transform duration-200 {expanded
? 'rotate-180'
: ''}"
/>
</button>
<!-- Expanded -->
{#if expanded}
<div class="mt-1 mb-3 px-3.5 py-3 border border-gray-100 dark:border-gray-850 rounded-2xl">
<div class="flex items-center gap-3">
<button
type="button"
class="shrink-0 rounded-xl overflow-hidden hover:opacity-80 transition"
on:click={() => filesInputElement.click()}
>
<img
src={image || `${WEBUI_BASE_URL}/static/favicon.png`}
class="size-8 object-cover"
alt=""
/>
</button>
<div class="flex-1">
<div class=" text-gray-500 text-xs">{$i18n.t('Name')}</div>
<input
type="text"
class="w-full text-sm bg-transparent outline-none placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={name}
placeholder={$i18n.t('Webhook Name')}
/>
</div>
<div class="flex items-center gap-1">
<Tooltip content={$i18n.t('Copy URL')}>
<button
type="button"
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={copyUrl}
>
<Clipboard className="size-4 text-gray-500" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
type="button"
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={onDelete}
>
<GarbageBin className="size-4 text-gray-500" />
</button>
</Tooltip>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
import {
getChannelWebhooks,
createChannelWebhook,
updateChannelWebhook,
deleteChannelWebhook
} from '$lib/apis/channels';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import WebhookItem from './WebhookItem.svelte';
const i18n = getContext('i18n');
export let show = false;
export let channel = null;
let webhooks = [];
let isLoading = false;
let isSaving = false;
let showDeleteConfirmDialog = false;
let selectedWebhookId = null;
// Track pending changes from child components
let pendingChanges: { [webhookId: string]: { name: string; profile_image_url: string } } = {};
const loadWebhooks = async () => {
isLoading = true;
try {
webhooks = await getChannelWebhooks(localStorage.token, channel.id);
} catch {
webhooks = [];
}
isLoading = false;
};
const createHandler = async () => {
isSaving = true;
try {
const newWebhook = await createChannelWebhook(localStorage.token, channel.id, {
name: 'New Webhook'
});
if (newWebhook) {
webhooks = [...webhooks, newWebhook];
selectedWebhookId = newWebhook.id;
}
} catch (error) {
toast.error(`${error}`);
}
isSaving = false;
};
const saveHandler = async () => {
isSaving = true;
try {
for (const [webhookId, changes] of Object.entries(pendingChanges)) {
await updateChannelWebhook(localStorage.token, channel.id, webhookId, changes);
}
pendingChanges = {};
await loadWebhooks();
toast.success($i18n.t('Saved'));
} catch (error) {
toast.error(`${error}`);
}
isSaving = false;
};
const deleteHandler = async () => {
if (!selectedWebhookId) return;
try {
await deleteChannelWebhook(localStorage.token, channel.id, selectedWebhookId);
webhooks = webhooks.filter((webhook) => webhook.id !== selectedWebhookId);
toast.success($i18n.t('Deleted'));
} catch (error) {
toast.error(`${error}`);
}
selectedWebhookId = null;
showDeleteConfirmDialog = false;
};
$: if (show && channel) {
loadWebhooks();
selectedWebhookId = null;
pendingChanges = {};
}
</script>
<ConfirmDialog bind:show={showDeleteConfirmDialog} on:confirm={deleteHandler} />
{#if channel}
<Modal size="sm" bind:show>
<div>
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class="flex w-full justify-between items-center mr-3">
<div class="self-center text-base flex gap-1.5 items-center">
<div>{$i18n.t('Webhooks')}</div>
<span class="text-sm text-gray-500">{webhooks.length}</span>
</div>
<button
type="button"
class="px-3 py-1.5 gap-1 rounded-xl bg-gray-100/50 dark:bg-gray-850/50 text-black dark:text-white transition font-medium text-xs flex items-center justify-center"
on:click={createHandler}
disabled={isSaving}
>
<Plus className="size-3.5" />
<span>{$i18n.t('New Webhook')}</span>
</button>
</div>
<button class="self-center" on:click={() => (show = false)}>
<XMark className="size-5" />
</button>
</div>
<div class="flex flex-col w-full px-4 pb-4 dark:text-gray-200">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
saveHandler();
}}
>
{#if isLoading}
<div class="flex justify-center py-10">
<Spinner className="size-5" />
</div>
{:else if webhooks.length > 0}
<div class="w-full py-2">
{#each webhooks as webhook (webhook.id)}
<WebhookItem
{webhook}
expanded={selectedWebhookId === webhook.id}
onClick={() => {
selectedWebhookId = selectedWebhookId === webhook.id ? null : webhook.id;
}}
onDelete={() => {
showDeleteConfirmDialog = true;
}}
onUpdate={(changes) => {
pendingChanges[webhook.id] = changes;
}}
/>
{/each}
</div>
{:else}
<div class="text-gray-500 text-xs text-center py-8 px-10">
{$i18n.t('No webhooks yet')}
</div>
{/if}
<div class="flex justify-end text-sm font-medium gap-1.5">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {isSaving
? 'cursor-not-allowed'
: ''}"
type="submit"
disabled={isSaving}
>
{$i18n.t('Save')}
{#if isSaving}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</Modal>
{/if}

View File

@@ -17,6 +17,7 @@
import MemberSelector from '$lib/components/workspace/common/MemberSelector.svelte';
import Visibility from '$lib/components/workspace/common/Visibility.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import WebhooksModal from '$lib/components/channel/WebhooksModal.svelte';
export let show = false;
export let onSubmit: Function = () => {};
@@ -97,6 +98,7 @@
}
let showDeleteConfirmDialog = false;
let showWebhooksModal = false;
const deleteHandler = async () => {
showDeleteConfirmDialog = false;
@@ -126,7 +128,7 @@
};
</script>
<Modal size="sm" bind:show>
<Modal size="md" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
<div class=" text-lg font-medium self-center">
@@ -247,6 +249,22 @@
</div>
{/if}
{#if edit}
<div class="flex w-full mt-2 items-center justify-between">
<div class="text-xs text-gray-500">{$i18n.t('Webhooks')}</div>
<button
class="text-xs bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden text-left"
type="button"
on:click={() => {
showWebhooksModal = true;
}}
>
{$i18n.t('Manage')}
</button>
</div>
{/if}
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
{#if edit}
<button
@@ -294,3 +312,5 @@
deleteHandler();
}}
/>
<WebhooksModal bind:show={showWebhooksModal} {channel} />