mirror of
https://github.com/open-webui/open-webui.git
synced 2026-02-24 04:00:31 +01:00
feat: channel webhooks
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
164
src/lib/components/channel/WebhookItem.svelte
Normal file
164
src/lib/components/channel/WebhookItem.svelte
Normal 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>
|
||||
181
src/lib/components/channel/WebhooksModal.svelte
Normal file
181
src/lib/components/channel/WebhooksModal.svelte
Normal 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}
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user