This commit is contained in:
Timothy Jaeryang Baek 2026-10-13 18:32:14 +04:00
parent 200b951491
commit a94a2068e2
5 changed files with 117 additions and 24 deletions

View file

@ -52,10 +52,10 @@ from open_webui.tasks import create_task
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.apps import (
check_app_channel_access,
generate_app_channel_reply,
get_app_by_id,
get_app_response,
has_app_access,
send_app_mention,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.channels import extract_mentions, replace_mentions
@ -1110,11 +1110,10 @@ async def app_channel_reply_handler(channel_id, message_id, user_id):
user = await Users.get_user_by_id(user_id, db=db)
if not user:
continue
value = await generate_app_channel_reply(
await send_app_mention(
app,
user,
channel_id,
'channel.mention',
{'text': text, 'message_id': message_id, 'parent_id': parent_id},
operation_id=message_id,
db=db,
@ -1124,11 +1123,15 @@ async def app_channel_reply_handler(channel_id, message_id, user_id):
)
except Exception as exc:
log.warning('App mention delivery failed: %s', type(exc).__name__)
value = {
'type': 'error',
'content': 'App could not respond. Check access and connection status before retrying.',
}
if value.get('type') != 'ack' and value.get('visibility') != 'channel':
status_code = getattr(exc, 'status_code', None)
if status_code == 504:
detail = 'App delivery was not confirmed. It may have accepted the message; check the channel before retrying.'
elif status_code in (401, 403):
detail = 'App access was denied. Check permissions before retrying.'
elif status_code == 503:
detail = 'App is unavailable or busy. The message was not accepted.'
else:
detail = 'App delivery failed. Check its connection and server logs before retrying.'
await emit_to_users(
'events:app',
{
@ -1137,7 +1140,7 @@ async def app_channel_reply_handler(channel_id, message_id, user_id):
'channel_id': channel_id,
'message_id': message_id,
'parent_id': parent_id,
'reply': value,
'reply': {'type': 'error', 'content': detail},
},
[user_id],
)

View file

@ -460,8 +460,8 @@ async def update_app_message(db, app, message_id, form, if_match):
return value
async def generate_app_channel_reply(
app, user, channel_id, event_type, data, *, db: AsyncSession, source=None, operation_id=None, scope=None
async def send_app_invocation(
app, user, channel_id, event_type, data, *, db: AsyncSession, source=None, operation_id=None, scope=None, timeout=3
):
await check_app_invocation_access(app, channel_id, user, db=db)
require_scope(app, scope)
@ -488,11 +488,10 @@ async def generate_app_channel_reply(
'execution_token': token,
'source': source,
}
checked_key = (app.api_key, app.data['manifest']['public_key'])
# Release the read transaction before HTTP; recheck access after the response.
# Release the read transaction before socket delivery.
await db.commit()
try:
status, raw = await send_app_event(app, envelope)
status, raw = await send_app_event(app, envelope, timeout=timeout)
except HTTPException as exc:
if exc.status_code == 504:
raise HTTPException(504, 'App response timed out; execution outcome may be unknown') from None
@ -509,7 +508,41 @@ async def generate_app_channel_reply(
503: 'App is busy or shutting down. The event was not accepted.',
504: 'App processing timed out; execution outcome may be unknown.',
}.get(status, f'App returned HTTP {status}. Check the app server logs.')
raise HTTPException(504 if status == 504 else 502, detail)
raise HTTPException(status if status in (401, 403, 503, 504) else 502, detail)
return raw
async def send_app_mention(app, user, channel_id, data, *, operation_id, scope, db):
receipt = await send_app_invocation(
app,
user,
channel_id,
'channel.mention',
data,
operation_id=operation_id,
scope=scope,
db=db,
timeout=10,
)
if receipt != {'received': True}:
raise HTTPException(502, 'App returned an invalid delivery receipt')
async def generate_app_channel_reply(
app, user, channel_id, event_type, data, *, db: AsyncSession, source=None, operation_id=None, scope=None
):
checked_key = (app.api_key, app.data['manifest']['public_key'])
raw = await send_app_invocation(
app,
user,
channel_id,
event_type,
data,
db=db,
source=source,
operation_id=operation_id,
scope=scope,
)
app = await get_app_by_id(app.id, require_active=True, db=db)
if (app.api_key, app.data['manifest']['public_key']) != checked_key:
raise HTTPException(403, 'App credentials changed')

View file

@ -62,7 +62,9 @@
{#if reply?.title}<h3 class="font-medium">{reply.title}</h3>{/if}
{#if reply?.content}<Markdown content={reply.content} allowEmbeds={false} />{/if}
{#each reply.embeds ?? [] as embed}<AppEmbed {embed} />{/each}
{#if reply?.type === 'error'}<p role="alert">The app could not complete this operation.</p>{/if}
{#if reply?.type === 'error' && !reply?.content}<p role="alert">
The app could not complete this operation.
</p>{/if}
{#if error}<p role="alert" class="text-red-600">{error}</p>{/if}
{#if reply?.type === 'download'}<button
class="focus-ring flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs font-normal text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"

View file

@ -1,14 +1,14 @@
<script lang="ts">
import { getContext } from 'svelte';
import { LinkPreview } from 'bits-ui';
const i18n = getContext('i18n');
import { getUserInfoById } from '$lib/apis/users';
import UserStatus from './UserStatus.svelte';
export let id: string | null = null;
export let openPreview = false;
export let resolvedUser = null;
export let loadingUser = false;
export let side = 'top';
export let align = 'start';
@ -34,7 +34,10 @@
}
};
$: if (openPreview && id && id !== requestedUserId) {
$: if (resolvedUser?.id === id) {
user = resolvedUser;
requestedUserId = id;
} else if (!loadingUser && openPreview && id && id !== requestedUserId) {
loadUser(id);
} else if (!openPreview) {
requestedUserId = null;

View file

@ -1,12 +1,28 @@
<script context="module" lang="ts">
import { getUserInfoById } from '$lib/apis/users';
const pendingUsers = new Map<string, ReturnType<typeof getUserInfoById>>();
function loadMentionUser(token: string, id: string) {
const key = `${token}:${id}`;
if (!pendingUsers.has(key)) {
pendingUsers.set(
key,
getUserInfoById(token, id).finally(() => pendingUsers.delete(key))
);
}
return pendingUsers.get(key)!;
}
</script>
<script lang="ts">
import type { Token } from 'marked';
import { LinkPreview } from 'bits-ui';
import { getContext } from 'svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { channels, models } from '$lib/stores';
import UserStatus from '$lib/components/channel/Messages/Message/UserStatus.svelte';
import UserStatusLinkPreview from '$lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte';
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
@ -19,12 +35,15 @@
let idType = null;
let id = '';
let openPreview = false;
let resolvedUser = null;
let requestedUserId: string | null = null;
let userUnavailable = false;
$: if (token && $i18n && $models && $channels) {
init();
init(resolvedUser, userUnavailable);
}
const init = () => {
const init = (user, unavailable) => {
const _id = token?.id;
triggerChar = token?.triggerChar ?? '@';
@ -68,7 +87,12 @@
}
} else if (triggerChar === '@') {
if (idType === 'U') {
// User
label =
user?.id === id
? user.name
: unavailable && requestedUserId === id
? $i18n.t('Unknown')
: label;
} else if (idType === 'M') {
// Model
const model = $models.find((m) => m.id === id);
@ -79,7 +103,30 @@
}
}
}
if (
browser &&
idType === 'U' &&
requestedUserId !== id &&
(!token?.label || token.label === id || /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(token.label))
) {
requestedUserId = id;
resolvedUser = null;
userUnavailable = false;
resolveUser(id);
}
};
async function resolveUser(userId: string) {
try {
const user = await loadMentionUser(localStorage.token, userId);
if (requestedUserId === userId) {
resolvedUser = user;
userUnavailable = !user;
}
} catch {
if (requestedUserId === userId) userUnavailable = true;
}
}
</script>
<LinkPreview.Root openDelay={0} closeDelay={0} bind:open={openPreview}>
@ -118,6 +165,11 @@
</LinkPreview.Trigger>
{#if triggerChar === '@' && idType === 'U'}
<UserStatusLinkPreview {id} {openPreview} />
<UserStatusLinkPreview
{id}
{openPreview}
resolvedUser={resolvedUser?.id === id ? resolvedUser : null}
loadingUser={requestedUserId === id && !resolvedUser && !userUnavailable}
/>
{/if}
</LinkPreview.Root>