feat: channel webhooks
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -245,6 +246,11 @@ class CreateChannelForm(ChannelForm):
|
|||||||
type: Optional[str] = None
|
type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelWebhookForm(BaseModel):
|
||||||
|
name: str
|
||||||
|
profile_image_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ChannelTable:
|
class ChannelTable:
|
||||||
|
|
||||||
def _collect_unique_user_ids(
|
def _collect_unique_user_ids(
|
||||||
@@ -945,5 +951,115 @@ class ChannelTable:
|
|||||||
db.commit()
|
db.commit()
|
||||||
return True
|
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()
|
Channels = ChannelTable()
|
||||||
|
|||||||
@@ -199,11 +199,32 @@ class MessageTable:
|
|||||||
if include_thread_replies:
|
if include_thread_replies:
|
||||||
thread_replies = self.get_thread_replies_by_message_id(id, db=db)
|
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(
|
return MessageResponse.model_validate(
|
||||||
{
|
{
|
||||||
**MessageModel.model_validate(message).model_dump(),
|
**MessageModel.model_validate(message).model_dump(),
|
||||||
"user": user.model_dump() if user else None,
|
"user": user_info,
|
||||||
"reply_to_message": (
|
"reply_to_message": (
|
||||||
reply_to_message.model_dump() if reply_to_message else None
|
reply_to_message.model_dump() if reply_to_message else None
|
||||||
),
|
),
|
||||||
@@ -235,10 +256,29 @@ class MessageTable:
|
|||||||
if message.reply_to_id
|
if message.reply_to_id
|
||||||
else None
|
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(
|
messages.append(
|
||||||
MessageReplyToResponse.model_validate(
|
MessageReplyToResponse.model_validate(
|
||||||
{
|
{
|
||||||
**MessageModel.model_validate(message).model_dump(),
|
**MessageModel.model_validate(message).model_dump(),
|
||||||
|
"user": user_info,
|
||||||
"reply_to_message": (
|
"reply_to_message": (
|
||||||
reply_to_message.model_dump()
|
reply_to_message.model_dump()
|
||||||
if reply_to_message
|
if reply_to_message
|
||||||
@@ -284,10 +324,29 @@ class MessageTable:
|
|||||||
if message.reply_to_id
|
if message.reply_to_id
|
||||||
else None
|
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(
|
messages.append(
|
||||||
MessageReplyToResponse.model_validate(
|
MessageReplyToResponse.model_validate(
|
||||||
{
|
{
|
||||||
**MessageModel.model_validate(message).model_dump(),
|
**MessageModel.model_validate(message).model_dump(),
|
||||||
|
"user": user_info,
|
||||||
"reply_to_message": (
|
"reply_to_message": (
|
||||||
reply_to_message.model_dump()
|
reply_to_message.model_dump()
|
||||||
if reply_to_message
|
if reply_to_message
|
||||||
@@ -334,10 +393,29 @@ class MessageTable:
|
|||||||
if message.reply_to_id
|
if message.reply_to_id
|
||||||
else None
|
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(
|
messages.append(
|
||||||
MessageReplyToResponse.model_validate(
|
MessageReplyToResponse.model_validate(
|
||||||
{
|
{
|
||||||
**MessageModel.model_validate(message).model_dump(),
|
**MessageModel.model_validate(message).model_dump(),
|
||||||
|
"user": user_info,
|
||||||
"reply_to_message": (
|
"reply_to_message": (
|
||||||
reply_to_message.model_dump()
|
reply_to_message.model_dump()
|
||||||
if reply_to_message
|
if reply_to_message
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
|
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
|
||||||
|
from fastapi.responses import Response, StreamingResponse, FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
|
|
||||||
@@ -29,6 +32,8 @@ from open_webui.models.channels import (
|
|||||||
ChannelForm,
|
ChannelForm,
|
||||||
ChannelResponse,
|
ChannelResponse,
|
||||||
CreateChannelForm,
|
CreateChannelForm,
|
||||||
|
ChannelWebhookModel,
|
||||||
|
ChannelWebhookForm,
|
||||||
)
|
)
|
||||||
from open_webui.models.messages import (
|
from open_webui.models.messages import (
|
||||||
Messages,
|
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.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
|
||||||
from open_webui.constants import ERROR_MESSAGES
|
from open_webui.constants import ERROR_MESSAGES
|
||||||
|
from open_webui.env import STATIC_DIR
|
||||||
|
|
||||||
|
|
||||||
from open_webui.utils.models import (
|
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
|
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(
|
messages.append(
|
||||||
MessageUserResponse(
|
MessageUserResponse(
|
||||||
**{
|
**{
|
||||||
@@ -831,7 +842,7 @@ async def get_channel_messages(
|
|||||||
"reactions": Messages.get_reactions_by_message_id(
|
"reactions": Messages.get_reactions_by_message_id(
|
||||||
message.id, db=db
|
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 = []
|
messages = []
|
||||||
for message in message_list:
|
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(
|
messages.append(
|
||||||
MessageWithReactionsResponse(
|
MessageWithReactionsResponse(
|
||||||
**{
|
**{
|
||||||
@@ -896,7 +920,7 @@ async def get_pinned_channel_messages(
|
|||||||
"reactions": Messages.get_reactions_by_message_id(
|
"reactions": Messages.get_reactions_by_message_id(
|
||||||
message.id, db=db
|
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 = []
|
messages = []
|
||||||
for message in message_list:
|
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(
|
messages.append(
|
||||||
MessageUserResponse(
|
MessageUserResponse(
|
||||||
**{
|
**{
|
||||||
@@ -1485,7 +1514,7 @@ async def get_channel_thread_messages(
|
|||||||
"reactions": Messages.get_reactions_by_message_id(
|
"reactions": Messages.get_reactions_by_message_id(
|
||||||
message.id, db=db
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
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;
|
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 (type === 'message') {
|
||||||
if ((data?.parent_id ?? null) === null) {
|
if ((data?.parent_id ?? null) === null) {
|
||||||
const tempId = data?.temp_id ?? null;
|
const tempId = data?.temp_id ?? null;
|
||||||
messages = [{ ...data, temp_id: null }, ...messages.filter((m) => m?.temp_id !== tempId)];
|
messages = [{ ...data, temp_id: null }, ...messages.filter((m) => !tempId || m?.temp_id !== tempId)];
|
||||||
|
|
||||||
if (typingUsers.find((user) => user.id === event.user.id)) {
|
if (typingUsers.find((user) => user.id === event.user.id)) {
|
||||||
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
|
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
|
||||||
|
|||||||
@@ -131,8 +131,9 @@
|
|||||||
replyToMessage={replyToMessage?.id === message.id}
|
replyToMessage={replyToMessage?.id === message.id}
|
||||||
disabled={!channel?.write_access || message?.temp_id}
|
disabled={!channel?.write_access || message?.temp_id}
|
||||||
pending={!!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)?.user?.id !== message.user?.id ||
|
||||||
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id ||
|
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id ||
|
||||||
message?.reply_to_message !== null}
|
message?.reply_to_message !== null}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
|
|||||||
@@ -245,7 +245,7 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<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')}
|
alt={message.reply_to_message.user?.name ?? $i18n.t('Unknown User')}
|
||||||
class="size-4 ml-0.5 rounded-full object-cover"
|
class="size-4 ml-0.5 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -277,10 +277,10 @@
|
|||||||
alt={message.meta.model_name ?? message.meta.model_id}
|
alt={message.meta.model_name ?? message.meta.model_id}
|
||||||
class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
|
class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<ProfilePreview user={message.user}>
|
<ProfilePreview user={message.user}>
|
||||||
<ProfileImage
|
<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'}
|
className={'size-8 ml-0.5'}
|
||||||
/>
|
/>
|
||||||
</ProfilePreview>
|
</ProfilePreview>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 MemberSelector from '$lib/components/workspace/common/MemberSelector.svelte';
|
||||||
import Visibility from '$lib/components/workspace/common/Visibility.svelte';
|
import Visibility from '$lib/components/workspace/common/Visibility.svelte';
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
|
import WebhooksModal from '$lib/components/channel/WebhooksModal.svelte';
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
export let onSubmit: Function = () => {};
|
export let onSubmit: Function = () => {};
|
||||||
@@ -97,6 +98,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let showDeleteConfirmDialog = false;
|
let showDeleteConfirmDialog = false;
|
||||||
|
let showWebhooksModal = false;
|
||||||
|
|
||||||
const deleteHandler = async () => {
|
const deleteHandler = async () => {
|
||||||
showDeleteConfirmDialog = false;
|
showDeleteConfirmDialog = false;
|
||||||
@@ -126,7 +128,7 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal size="sm" bind:show>
|
<Modal size="md" bind:show>
|
||||||
<div>
|
<div>
|
||||||
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
|
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
|
||||||
<div class=" text-lg font-medium self-center">
|
<div class=" text-lg font-medium self-center">
|
||||||
@@ -247,6 +249,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
|
||||||
{#if edit}
|
{#if edit}
|
||||||
<button
|
<button
|
||||||
@@ -294,3 +312,5 @@
|
|||||||
deleteHandler();
|
deleteHandler();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WebhooksModal bind:show={showWebhooksModal} {channel} />
|
||||||
|
|||||||
Reference in New Issue
Block a user