mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-16 23:43:03 +00:00
- Seq key moved from `{prefix}:stream:{user}:{msg}:seq` to
`{prefix}:streamseq:{user}:{msg}`. A message_id containing a literal
`:seq` suffix would otherwise make its stream key equal to another
message's seq key, which under a user-controlled message_id would
collide INCR against an XADD on the same Redis key.
- clearResumeFence swap-and-iterate instead of shift() in a loop.
Array.shift is O(n) per call, so the old drain was O(n²). Practical
frame counts during a fence window are small (dozens worst-case) so
this was never a user-visible problem, but the fix is two lines and
removes a Big-O footgun. Concurrency behavior unchanged: the batch
is captured via reference-swap so frames arriving during an await
continue buffering into the fresh empty array still present in the
map, and the outer while loop drains those in the next iteration.
Not addressed:
- Concurrent-emitter out-of-seq live frames — already documented as a
known limitation with a block comment at get_event_emitter; fix
requires distributed locking.
- Prune resumeSeqByMessageId on per-message terminal events —
deliberately removed two rounds ago because it caused continuation-
reuses-message_id to replay duplicate content; the trade-off
(bounded int-per-message memory vs. correctness) is already
captured in that commit message.
1223 lines
41 KiB
Python
1223 lines
41 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
import random
|
|
|
|
import socketio
|
|
import logging
|
|
import sys
|
|
import time
|
|
from typing import Dict, Set
|
|
from redis import asyncio as aioredis
|
|
import pycrdt as Y
|
|
|
|
from open_webui.models.users import Users, UserNameResponse
|
|
from open_webui.models.channels import Channels
|
|
from open_webui.models.chats import Chats
|
|
from open_webui.models.notes import Notes, NoteUpdateForm
|
|
from open_webui.utils.redis import (
|
|
get_sentinels_from_env,
|
|
get_sentinel_url_from_env,
|
|
)
|
|
|
|
from open_webui.config import (
|
|
CORS_ALLOW_ORIGIN,
|
|
)
|
|
|
|
from open_webui.env import (
|
|
VERSION,
|
|
ENABLE_REALTIME_CHAT_SAVE,
|
|
ENABLE_WEBSOCKET_SUPPORT,
|
|
WEBSOCKET_MANAGER,
|
|
WEBSOCKET_REDIS_URL,
|
|
WEBSOCKET_REDIS_CLUSTER,
|
|
WEBSOCKET_REDIS_LOCK_TIMEOUT,
|
|
WEBSOCKET_SENTINEL_PORT,
|
|
WEBSOCKET_SENTINEL_HOSTS,
|
|
REDIS_KEY_PREFIX,
|
|
WEBSOCKET_REDIS_OPTIONS,
|
|
WEBSOCKET_SERVER_PING_TIMEOUT,
|
|
WEBSOCKET_SERVER_PING_INTERVAL,
|
|
WEBSOCKET_SERVER_LOGGING,
|
|
WEBSOCKET_SERVER_ENGINEIO_LOGGING,
|
|
WEBSOCKET_EVENT_CALLER_TIMEOUT,
|
|
)
|
|
from open_webui.utils.auth import decode_token
|
|
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
|
|
from open_webui.tasks import create_task, stop_item_tasks
|
|
from open_webui.utils.redis import get_redis_connection
|
|
from open_webui.utils.access_control import has_permission
|
|
from open_webui.models.access_grants import AccessGrants
|
|
|
|
|
|
from open_webui.env import (
|
|
GLOBAL_LOG_LEVEL,
|
|
)
|
|
|
|
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# Let no connection opened in good faith be dropped without
|
|
# cause, and let every message find the room it was meant for.
|
|
REDIS = None
|
|
|
|
# Configure CORS for Socket.IO
|
|
SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN
|
|
|
|
if WEBSOCKET_MANAGER == 'redis':
|
|
if WEBSOCKET_SENTINEL_HOSTS:
|
|
mgr = socketio.AsyncRedisManager(
|
|
get_sentinel_url_from_env(WEBSOCKET_REDIS_URL, WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT),
|
|
redis_options=WEBSOCKET_REDIS_OPTIONS,
|
|
)
|
|
else:
|
|
mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL, redis_options=WEBSOCKET_REDIS_OPTIONS)
|
|
sio = socketio.AsyncServer(
|
|
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
|
async_mode='asgi',
|
|
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
|
|
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
|
|
always_connect=True,
|
|
client_manager=mgr,
|
|
logger=WEBSOCKET_SERVER_LOGGING,
|
|
ping_interval=WEBSOCKET_SERVER_PING_INTERVAL,
|
|
ping_timeout=WEBSOCKET_SERVER_PING_TIMEOUT,
|
|
engineio_logger=WEBSOCKET_SERVER_ENGINEIO_LOGGING,
|
|
)
|
|
else:
|
|
sio = socketio.AsyncServer(
|
|
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
|
async_mode='asgi',
|
|
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
|
|
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
|
|
always_connect=True,
|
|
logger=WEBSOCKET_SERVER_LOGGING,
|
|
ping_interval=WEBSOCKET_SERVER_PING_INTERVAL,
|
|
ping_timeout=WEBSOCKET_SERVER_PING_TIMEOUT,
|
|
engineio_logger=WEBSOCKET_SERVER_ENGINEIO_LOGGING,
|
|
)
|
|
|
|
|
|
# Timeout duration in seconds
|
|
TIMEOUT_DURATION = 3
|
|
SESSION_POOL_TIMEOUT = 120 # seconds without heartbeat before session is reaped
|
|
|
|
# Dictionary to maintain the user pool
|
|
|
|
if WEBSOCKET_MANAGER == 'redis':
|
|
log.debug('Using Redis to manage websockets.')
|
|
REDIS = get_redis_connection(
|
|
redis_url=WEBSOCKET_REDIS_URL,
|
|
redis_sentinels=get_sentinels_from_env(WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT),
|
|
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
|
|
async_mode=True,
|
|
)
|
|
|
|
redis_sentinels = get_sentinels_from_env(WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT)
|
|
|
|
MODELS = RedisDict(
|
|
f'{REDIS_KEY_PREFIX}:models',
|
|
redis_url=WEBSOCKET_REDIS_URL,
|
|
redis_sentinels=redis_sentinels,
|
|
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
|
|
)
|
|
|
|
SESSION_POOL = RedisDict(
|
|
f'{REDIS_KEY_PREFIX}:session_pool',
|
|
redis_url=WEBSOCKET_REDIS_URL,
|
|
redis_sentinels=redis_sentinels,
|
|
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
|
|
)
|
|
USAGE_POOL = RedisDict(
|
|
f'{REDIS_KEY_PREFIX}:usage_pool',
|
|
redis_url=WEBSOCKET_REDIS_URL,
|
|
redis_sentinels=redis_sentinels,
|
|
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
|
|
)
|
|
|
|
clean_up_lock = RedisLock(
|
|
redis_url=WEBSOCKET_REDIS_URL,
|
|
lock_name=f'{REDIS_KEY_PREFIX}:usage_cleanup_lock',
|
|
timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT,
|
|
redis_sentinels=redis_sentinels,
|
|
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
|
|
)
|
|
aquire_func = clean_up_lock.aquire_lock
|
|
renew_func = clean_up_lock.renew_lock
|
|
release_func = clean_up_lock.release_lock
|
|
|
|
session_cleanup_lock = RedisLock(
|
|
redis_url=WEBSOCKET_REDIS_URL,
|
|
lock_name=f'{REDIS_KEY_PREFIX}:session_cleanup_lock',
|
|
timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT,
|
|
redis_sentinels=redis_sentinels,
|
|
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
|
|
)
|
|
session_aquire_func = session_cleanup_lock.aquire_lock
|
|
session_renew_func = session_cleanup_lock.renew_lock
|
|
session_release_func = session_cleanup_lock.release_lock
|
|
else:
|
|
MODELS = {}
|
|
|
|
SESSION_POOL = {}
|
|
USAGE_POOL = {}
|
|
|
|
aquire_func = release_func = renew_func = lambda: True
|
|
session_aquire_func = session_release_func = session_renew_func = lambda: True
|
|
|
|
|
|
YDOC_MANAGER = YdocManager(
|
|
redis=REDIS,
|
|
redis_key_prefix=f'{REDIS_KEY_PREFIX}:ydoc:documents',
|
|
)
|
|
|
|
|
|
# Bounded Redis stream log keyed by message_id. Clients replay from it
|
|
# on reconnect. No-op without Redis. Also no-op in REALTIME_CHAT_SAVE
|
|
# mode — DB is authoritative there and replay would double-apply.
|
|
RESUME_STREAM_MAXLEN = 2000
|
|
RESUME_STREAM_TTL_SEC = 3600
|
|
RESUME_STREAM_DONE_TTL_SEC = 30
|
|
RESUME_STREAM_TTL_REFRESH_EVERY = 64
|
|
# Hot-path timeout: tight so a slow Redis can't stall live tokens.
|
|
# Replay read timeout: looser since a resume is user-blocking anyway
|
|
# and silent timeout here is worse than a brief extra wait. Both
|
|
# configurable for infra where Redis isn't colocated.
|
|
RESUME_STREAM_REDIS_TIMEOUT_SEC = float(
|
|
os.environ.get('RESUME_STREAM_REDIS_TIMEOUT_SEC', '0.1')
|
|
)
|
|
RESUME_STREAM_READ_TIMEOUT_SEC = float(
|
|
os.environ.get('RESUME_STREAM_READ_TIMEOUT_SEC', '1.0')
|
|
)
|
|
|
|
# Module-level circuit breaker for the streaming hot path. After N
|
|
# consecutive Redis failures/timeouts, short-circuit seq/log calls for
|
|
# a cool-down window so every outgoing frame doesn't pay the timeout
|
|
# wall-clock cost during a sustained outage. Global (not per-message)
|
|
# because when Redis is the thing that's unhealthy, it's unhealthy for
|
|
# everyone.
|
|
_HOT_PATH_BREAKER_FAILURE_THRESHOLD = 3
|
|
_HOT_PATH_BREAKER_COOLDOWN_SEC = 10.0
|
|
_hot_path_breaker = {'failures': 0, 'open_until': 0.0}
|
|
|
|
|
|
def _breaker_open() -> bool:
|
|
return time.time() < _hot_path_breaker['open_until']
|
|
|
|
|
|
def _breaker_record_success() -> None:
|
|
if _hot_path_breaker['failures']:
|
|
_hot_path_breaker['failures'] = 0
|
|
_hot_path_breaker['open_until'] = 0.0
|
|
|
|
|
|
def _breaker_record_failure() -> None:
|
|
_hot_path_breaker['failures'] += 1
|
|
if _hot_path_breaker['failures'] >= _HOT_PATH_BREAKER_FAILURE_THRESHOLD:
|
|
_hot_path_breaker['open_until'] = time.time() + _HOT_PATH_BREAKER_COOLDOWN_SEC
|
|
|
|
|
|
def _stream_key(user_id: str, message_id: str) -> str:
|
|
# user_id-scoped key — auth is implicit from the session's user.
|
|
return f'{REDIS_KEY_PREFIX}:stream:{user_id}:{message_id}'
|
|
|
|
|
|
def _stream_seq_key(user_id: str, message_id: str) -> str:
|
|
# Distinct top-level namespace (`streamseq`, not `stream`) so a
|
|
# message_id containing delimiter-like characters can't collide the
|
|
# seq key of one message with the stream key of another.
|
|
return f'{REDIS_KEY_PREFIX}:streamseq:{user_id}:{message_id}'
|
|
|
|
|
|
async def _stream_seq_allocate(user_id: str, message_id: str):
|
|
"""Allocate the next seq via atomic INCR, or None when resume is off."""
|
|
if ENABLE_REALTIME_CHAT_SAVE:
|
|
return None
|
|
if REDIS is None or not user_id or not message_id:
|
|
return None
|
|
if _breaker_open():
|
|
return None
|
|
try:
|
|
key = _stream_seq_key(user_id, message_id)
|
|
seq = await asyncio.wait_for(
|
|
REDIS.incr(key), timeout=RESUME_STREAM_REDIS_TIMEOUT_SEC
|
|
)
|
|
_breaker_record_success()
|
|
if seq == 1:
|
|
try:
|
|
await asyncio.wait_for(
|
|
REDIS.expire(key, RESUME_STREAM_TTL_SEC),
|
|
timeout=RESUME_STREAM_REDIS_TIMEOUT_SEC,
|
|
)
|
|
except Exception:
|
|
pass
|
|
return int(seq)
|
|
except asyncio.TimeoutError:
|
|
_breaker_record_failure()
|
|
log.warning(f'stream resume seq alloc timed out for {message_id}')
|
|
return None
|
|
except Exception as e:
|
|
_breaker_record_failure()
|
|
log.warning(f'stream resume seq alloc failed for {message_id}: {e}')
|
|
return None
|
|
|
|
|
|
async def _stream_log_append(user_id: str, message_id: str, envelope: dict, seq: int) -> None:
|
|
"""Append envelope to resume log. Timeout drops the entry, not the emit."""
|
|
if REDIS is None or not user_id or not message_id:
|
|
return
|
|
if _breaker_open():
|
|
return
|
|
try:
|
|
refresh_ttl = (seq == 1) or (seq % RESUME_STREAM_TTL_REFRESH_EVERY == 0)
|
|
key = _stream_key(user_id, message_id)
|
|
seq_key = _stream_seq_key(user_id, message_id)
|
|
pipe = REDIS.pipeline(transaction=False)
|
|
pipe.xadd(
|
|
key,
|
|
{'seq': str(seq), 'payload': json.dumps(envelope)},
|
|
maxlen=RESUME_STREAM_MAXLEN,
|
|
approximate=True,
|
|
)
|
|
if refresh_ttl:
|
|
# Refresh both keys together — without this the seq counter
|
|
# can expire mid-stream on responses longer than the TTL and
|
|
# INCR restarts at 1, corrupting replay ordering.
|
|
pipe.expire(key, RESUME_STREAM_TTL_SEC)
|
|
pipe.expire(seq_key, RESUME_STREAM_TTL_SEC)
|
|
await asyncio.wait_for(
|
|
pipe.execute(), timeout=RESUME_STREAM_REDIS_TIMEOUT_SEC
|
|
)
|
|
_breaker_record_success()
|
|
except asyncio.TimeoutError:
|
|
_breaker_record_failure()
|
|
log.warning(f'stream resume log append timed out for {message_id}')
|
|
except Exception as e:
|
|
_breaker_record_failure()
|
|
log.warning(f'stream resume log append failed for {message_id}: {e}')
|
|
|
|
|
|
async def _stream_log_read(user_id: str, message_id: str, after_seq: int):
|
|
"""Return envelopes with seq > after_seq, in order.
|
|
|
|
Full scan bounded by MAXLEN; Python filters by seq because auto IDs
|
|
don't encode it. With MAXLEN=2000 this is a few ms at worst and
|
|
resume is a rare, user-driven event so the cost is fine.
|
|
"""
|
|
if REDIS is None or not user_id or not message_id:
|
|
return []
|
|
try:
|
|
entries = await asyncio.wait_for(
|
|
REDIS.xrange(_stream_key(user_id, message_id), min='-', max='+'),
|
|
timeout=RESUME_STREAM_READ_TIMEOUT_SEC,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
log.warning(f'stream resume log read timed out for {message_id}')
|
|
return []
|
|
except Exception as e:
|
|
log.warning(f'stream resume log read failed for {message_id}: {e}')
|
|
return []
|
|
|
|
def _field(fields, key):
|
|
# redis-py may return bytes or str depending on decode_responses.
|
|
v = fields.get(key)
|
|
if v is None:
|
|
v = fields.get(key.encode() if isinstance(key, str) else key)
|
|
if isinstance(v, bytes):
|
|
v = v.decode('utf-8', 'replace')
|
|
return v
|
|
|
|
out = []
|
|
for _entry_id, fields in entries:
|
|
try:
|
|
entry_seq = int(_field(fields, 'seq') or '0')
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if entry_seq <= after_seq:
|
|
continue
|
|
payload = _field(fields, 'payload')
|
|
if not payload:
|
|
continue
|
|
try:
|
|
envelope = json.loads(payload)
|
|
except Exception:
|
|
continue
|
|
out.append((entry_seq, envelope))
|
|
# Stream append order can diverge from seq order under concurrent
|
|
# emitters (INCR/XADD aren't atomic); sort so the client gets frames
|
|
# in seq order.
|
|
out.sort(key=lambda pair: pair[0])
|
|
return [envelope for _seq, envelope in out]
|
|
|
|
|
|
async def periodic_session_pool_cleanup():
|
|
"""Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance)."""
|
|
if not session_aquire_func():
|
|
log.debug('Session cleanup lock held by another node. Skipping.')
|
|
return
|
|
|
|
try:
|
|
while True:
|
|
if not session_renew_func():
|
|
log.error('Unable to renew session cleanup lock. Exiting.')
|
|
return
|
|
|
|
now = int(time.time())
|
|
for sid in list(SESSION_POOL.keys()):
|
|
entry = SESSION_POOL.get(sid)
|
|
if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT:
|
|
log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})')
|
|
del SESSION_POOL[sid]
|
|
await asyncio.sleep(SESSION_POOL_TIMEOUT)
|
|
finally:
|
|
session_release_func()
|
|
|
|
|
|
async def periodic_usage_pool_cleanup():
|
|
max_retries = 2
|
|
retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT)
|
|
for attempt in range(max_retries + 1):
|
|
if aquire_func():
|
|
break
|
|
else:
|
|
if attempt < max_retries:
|
|
log.debug(f'Cleanup lock already exists. Retry {attempt + 1} after {retry_delay}s...')
|
|
await asyncio.sleep(retry_delay)
|
|
else:
|
|
log.warning('Failed to acquire cleanup lock after retries. Skipping cleanup.')
|
|
return
|
|
|
|
log.debug('Running periodic_cleanup')
|
|
try:
|
|
while True:
|
|
if not renew_func():
|
|
log.error(f'Unable to renew cleanup lock. Exiting usage pool cleanup.')
|
|
raise Exception('Unable to renew usage pool cleanup lock.')
|
|
|
|
now = int(time.time())
|
|
send_usage = False
|
|
for model_id, connections in list(USAGE_POOL.items()):
|
|
# Creating a list of sids to remove if they have timed out
|
|
expired_sids = [
|
|
sid for sid, details in connections.items() if now - details['updated_at'] > TIMEOUT_DURATION
|
|
]
|
|
|
|
for sid in expired_sids:
|
|
del connections[sid]
|
|
|
|
if not connections:
|
|
log.debug(f'Cleaning up model {model_id} from usage pool')
|
|
del USAGE_POOL[model_id]
|
|
else:
|
|
USAGE_POOL[model_id] = connections
|
|
|
|
send_usage = True
|
|
await asyncio.sleep(TIMEOUT_DURATION)
|
|
finally:
|
|
release_func()
|
|
|
|
|
|
app = socketio.ASGIApp(
|
|
sio,
|
|
socketio_path='/ws/socket.io',
|
|
)
|
|
|
|
|
|
def get_models_in_use():
|
|
# List models that are currently in use
|
|
models_in_use = list(USAGE_POOL.keys())
|
|
return models_in_use
|
|
|
|
|
|
def get_user_id_from_session_pool(sid):
|
|
user = SESSION_POOL.get(sid)
|
|
if user:
|
|
return user['id']
|
|
return None
|
|
|
|
|
|
def get_session_ids_from_room(room):
|
|
"""Get all session IDs from a specific room."""
|
|
active_session_ids = sio.manager.get_participants(
|
|
namespace='/',
|
|
room=room,
|
|
)
|
|
return [session_id[0] for session_id in active_session_ids]
|
|
|
|
|
|
def get_user_ids_from_room(room):
|
|
active_session_ids = get_session_ids_from_room(room)
|
|
|
|
active_user_ids = list(
|
|
set(
|
|
[
|
|
SESSION_POOL.get(session_id)['id']
|
|
for session_id in active_session_ids
|
|
if SESSION_POOL.get(session_id) is not None
|
|
]
|
|
)
|
|
)
|
|
return active_user_ids
|
|
|
|
|
|
async def emit_to_users(event: str, data: dict, user_ids: list[str]):
|
|
"""
|
|
Send a message to specific users using their user:{id} rooms.
|
|
|
|
Args:
|
|
event (str): The event name to emit.
|
|
data (dict): The payload/data to send.
|
|
user_ids (list[str]): The target users' IDs.
|
|
"""
|
|
try:
|
|
for user_id in user_ids:
|
|
await sio.emit(event, data, room=f'user:{user_id}')
|
|
except Exception as e:
|
|
log.debug(f'Failed to emit event {event} to users {user_ids}: {e}')
|
|
|
|
|
|
async def enter_room_for_users(room: str, user_ids: list[str]):
|
|
"""
|
|
Make all sessions of a user join a specific room.
|
|
Args:
|
|
room (str): The room to join.
|
|
user_ids (list[str]): The target user's IDs.
|
|
"""
|
|
try:
|
|
for user_id in user_ids:
|
|
session_ids = get_session_ids_from_room(f'user:{user_id}')
|
|
for sid in session_ids:
|
|
await sio.enter_room(sid, room)
|
|
except Exception as e:
|
|
log.debug(f'Failed to make users {user_ids} join room {room}: {e}')
|
|
|
|
|
|
async def disconnect_user_sessions(user_id: str):
|
|
"""Disconnect all Socket.IO sessions belonging to a user.
|
|
|
|
Call this when a user's role is changed or the user is deleted so that
|
|
stale role/permission data cached in SESSION_POOL is invalidated.
|
|
The client will automatically reconnect and re-authenticate with
|
|
fresh data from the database.
|
|
"""
|
|
try:
|
|
session_ids = get_session_ids_from_room(f'user:{user_id}')
|
|
for sid in session_ids:
|
|
await sio.disconnect(sid)
|
|
if session_ids:
|
|
log.info(f'Disconnected {len(session_ids)} session(s) for user {user_id}')
|
|
except Exception as e:
|
|
log.warning(f'Failed to disconnect sessions for user {user_id}: {e}')
|
|
|
|
|
|
@sio.on('usage')
|
|
async def usage(sid, data):
|
|
if sid in SESSION_POOL:
|
|
model_id = data['model']
|
|
# Record the timestamp for the last update
|
|
current_time = int(time.time())
|
|
|
|
# Store the new usage data and task
|
|
USAGE_POOL[model_id] = {
|
|
**(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}),
|
|
sid: {'updated_at': current_time},
|
|
}
|
|
|
|
|
|
@sio.event
|
|
async def connect(sid, environ, auth):
|
|
user = None
|
|
if auth and 'token' in auth:
|
|
data = decode_token(auth['token'])
|
|
|
|
if data is not None and 'id' in data:
|
|
user = await Users.get_user_by_id(data['id'])
|
|
|
|
if user:
|
|
SESSION_POOL[sid] = {
|
|
**user.model_dump(
|
|
exclude=[
|
|
'profile_image_url',
|
|
'profile_banner_image_url',
|
|
'date_of_birth',
|
|
'bio',
|
|
'gender',
|
|
]
|
|
),
|
|
'last_seen_at': int(time.time()),
|
|
}
|
|
await sio.enter_room(sid, f'user:{user.id}')
|
|
|
|
|
|
@sio.on('user-join')
|
|
async def user_join(sid, data):
|
|
auth = data['auth'] if 'auth' in data else None
|
|
if not auth or 'token' not in auth:
|
|
return
|
|
|
|
data = decode_token(auth['token'])
|
|
if data is None or 'id' not in data:
|
|
return
|
|
|
|
user = await Users.get_user_by_id(data['id'])
|
|
if not user:
|
|
return
|
|
|
|
SESSION_POOL[sid] = {
|
|
**user.model_dump(
|
|
exclude=[
|
|
'profile_image_url',
|
|
'profile_banner_image_url',
|
|
'date_of_birth',
|
|
'bio',
|
|
'gender',
|
|
]
|
|
),
|
|
'last_seen_at': int(time.time()),
|
|
}
|
|
|
|
await sio.enter_room(sid, f'user:{user.id}')
|
|
|
|
# Join all the channels only if user has channels permission
|
|
if user.role == 'admin' or await has_permission(user.id, 'features.channels'):
|
|
channels = await Channels.get_channels_by_user_id(user.id)
|
|
log.debug(f'{channels=}')
|
|
for channel in channels:
|
|
await sio.enter_room(sid, f'channel:{channel.id}')
|
|
|
|
return {'id': user.id, 'name': user.name}
|
|
|
|
|
|
@sio.on('heartbeat')
|
|
async def heartbeat(sid, data):
|
|
user = SESSION_POOL.get(sid)
|
|
if user:
|
|
SESSION_POOL[sid] = {**user, 'last_seen_at': int(time.time())}
|
|
await Users.update_last_active_by_id(user['id'])
|
|
|
|
|
|
@sio.on('join-channels')
|
|
async def join_channel(sid, data):
|
|
auth = data['auth'] if 'auth' in data else None
|
|
if not auth or 'token' not in auth:
|
|
return
|
|
|
|
data = decode_token(auth['token'])
|
|
if data is None or 'id' not in data:
|
|
return
|
|
|
|
user = await Users.get_user_by_id(data['id'])
|
|
if not user:
|
|
return
|
|
|
|
# Join all the channels only if user has channels permission
|
|
if user.role == 'admin' or await has_permission(user.id, 'features.channels'):
|
|
channels = await Channels.get_channels_by_user_id(user.id)
|
|
log.debug(f'{channels=}')
|
|
for channel in channels:
|
|
await sio.enter_room(sid, f'channel:{channel.id}')
|
|
|
|
|
|
@sio.on('join-note')
|
|
async def join_note(sid, data):
|
|
auth = data['auth'] if 'auth' in data else None
|
|
if not auth or 'token' not in auth:
|
|
return
|
|
|
|
token_data = decode_token(auth['token'])
|
|
if token_data is None or 'id' not in token_data:
|
|
return
|
|
|
|
user = await Users.get_user_by_id(token_data['id'])
|
|
if not user:
|
|
return
|
|
|
|
note = await Notes.get_note_by_id(data['note_id'])
|
|
if not note:
|
|
log.error(f'Note {data["note_id"]} not found for user {user.id}')
|
|
return
|
|
|
|
if (
|
|
user.role != 'admin'
|
|
and user.id != note.user_id
|
|
and not await AccessGrants.has_access(
|
|
user_id=user.id,
|
|
resource_type='note',
|
|
resource_id=note.id,
|
|
permission='read',
|
|
)
|
|
):
|
|
log.error(f'User {user.id} does not have access to note {data["note_id"]}')
|
|
return
|
|
|
|
log.debug(f'Joining note {note.id} for user {user.id}')
|
|
await sio.enter_room(sid, f'note:{note.id}')
|
|
|
|
|
|
@sio.on('events:channel')
|
|
async def channel_events(sid, data):
|
|
room = f'channel:{data["channel_id"]}'
|
|
participants = sio.manager.get_participants(
|
|
namespace='/',
|
|
room=room,
|
|
)
|
|
|
|
sids = [sid for sid, _ in participants]
|
|
if sid not in sids:
|
|
return
|
|
|
|
event_data = data['data']
|
|
event_type = event_data['type']
|
|
|
|
user = SESSION_POOL.get(sid)
|
|
|
|
if not user:
|
|
return
|
|
|
|
if event_type == 'typing':
|
|
await sio.emit(
|
|
'events:channel',
|
|
{
|
|
'channel_id': data['channel_id'],
|
|
'message_id': data.get('message_id', None),
|
|
'data': event_data,
|
|
'user': UserNameResponse(**user).model_dump(),
|
|
},
|
|
room=room,
|
|
)
|
|
elif event_type == 'last_read_at':
|
|
await Channels.update_member_last_read_at(data['channel_id'], user['id'])
|
|
|
|
|
|
@sio.on('events:chat')
|
|
async def chat_events(sid, data):
|
|
user = SESSION_POOL.get(sid)
|
|
if not user:
|
|
return
|
|
|
|
event_data = data.get('data', {})
|
|
event_type = event_data.get('type')
|
|
|
|
if event_type == 'last_read_at':
|
|
await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
|
|
|
|
|
|
@sio.on('resume-stream')
|
|
async def resume_stream(sid, data):
|
|
"""Batch replay emit; also serves as the client's fence-clear signal.
|
|
|
|
Reply whenever we have a message_id to target so the client fence
|
|
clears deterministically — even on auth failure. Silent drops only
|
|
happen when the client couldn't have raised a fence in the first
|
|
place (malformed payload, no message_id).
|
|
"""
|
|
if not isinstance(data, dict):
|
|
return
|
|
message_id = data.get('message_id')
|
|
if not message_id:
|
|
return
|
|
|
|
request_id = data.get('request_id')
|
|
envelopes = []
|
|
user = SESSION_POOL.get(sid)
|
|
user_id = user.get('id') if user else None
|
|
# Gate read on REALTIME mode to match the write-side gate. Otherwise
|
|
# stale logs written before the flag was flipped (or by a mixed-
|
|
# version peer) would still get replayed on top of DB-backed content
|
|
# and double-apply.
|
|
if user_id and REDIS is not None and not ENABLE_REALTIME_CHAT_SAVE:
|
|
try:
|
|
last_seq = int(data.get('last_seq') or 0)
|
|
except (TypeError, ValueError):
|
|
last_seq = 0
|
|
# Defense-in-depth: validate chat ownership even though the log
|
|
# key is already user-scoped. Rejects only when we can *prove*
|
|
# ownership fails — a missing/unknown chat_id falls through to
|
|
# the user-scoped key guarantee so we don't regress the "stub
|
|
# not yet persisted in DB" refresh case.
|
|
chat_id = data.get('chat_id')
|
|
chat_ok = True
|
|
if chat_id:
|
|
try:
|
|
chat = await Chats.get_chat_by_id_and_user_id(chat_id, user_id)
|
|
chat_ok = chat is not None
|
|
except Exception as e:
|
|
log.warning(f'resume-stream chat ownership check failed: {e}')
|
|
chat_ok = True # fail open to not regress legitimate callers
|
|
if chat_ok:
|
|
envelopes = await _stream_log_read(user_id, message_id, last_seq)
|
|
|
|
await sio.emit(
|
|
'resume-stream:replay',
|
|
{
|
|
'message_id': message_id,
|
|
'request_id': request_id,
|
|
'envelopes': envelopes,
|
|
},
|
|
to=sid,
|
|
)
|
|
|
|
|
|
def normalize_document_id(document_id: str) -> str:
|
|
"""Canonicalize document IDs to prevent auth bypass via prefix variants.
|
|
|
|
YdocManager normalizes storage keys by replacing ":" with "_", so
|
|
"note_abc" and "note:abc" resolve to the same underlying document.
|
|
We must rewrite underscore-prefixed IDs back to the colon form so
|
|
that authorization checks (which key on "note:") always fire.
|
|
"""
|
|
if document_id.startswith('note_'):
|
|
document_id = 'note:' + document_id[5:]
|
|
return document_id
|
|
|
|
|
|
@sio.on('ydoc:document:join')
|
|
async def ydoc_document_join(sid, data):
|
|
"""Handle user joining a document"""
|
|
user = SESSION_POOL.get(sid)
|
|
if not user:
|
|
return
|
|
|
|
try:
|
|
document_id = normalize_document_id(data['document_id'])
|
|
|
|
if document_id.startswith('note:'):
|
|
note_id = document_id.split(':')[1]
|
|
note = await Notes.get_note_by_id(note_id)
|
|
if not note:
|
|
log.error(f'Note {note_id} not found')
|
|
return
|
|
|
|
if (
|
|
user.get('role') != 'admin'
|
|
and user.get('id') != note.user_id
|
|
and not await AccessGrants.has_access(
|
|
user_id=user.get('id'),
|
|
resource_type='note',
|
|
resource_id=note.id,
|
|
permission='read',
|
|
)
|
|
):
|
|
log.error(f'User {user.get("id")} does not have access to note {note_id}')
|
|
return
|
|
|
|
user_id = data.get('user_id', sid)
|
|
user_name = data.get('user_name', 'Anonymous')
|
|
user_color = data.get('user_color', '#000000')
|
|
|
|
log.info(f'User {user_id} joining document {document_id}')
|
|
await YDOC_MANAGER.add_user(document_id=document_id, user_id=sid)
|
|
|
|
# Join Socket.IO room
|
|
await sio.enter_room(sid, f'doc_{document_id}')
|
|
|
|
active_session_ids = get_session_ids_from_room(f'doc_{document_id}')
|
|
|
|
# Get the Yjs document state
|
|
ydoc = Y.Doc()
|
|
updates = await YDOC_MANAGER.get_updates(document_id)
|
|
for update in updates:
|
|
ydoc.apply_update(bytes(update))
|
|
|
|
# Encode the entire document state as an update
|
|
state_update = ydoc.get_update()
|
|
await sio.emit(
|
|
'ydoc:document:state',
|
|
{
|
|
'document_id': document_id,
|
|
'state': list(state_update), # Convert bytes to list for JSON
|
|
'sessions': active_session_ids,
|
|
},
|
|
room=sid,
|
|
)
|
|
|
|
# Notify other users about the new user
|
|
await sio.emit(
|
|
'ydoc:user:joined',
|
|
{
|
|
'document_id': document_id,
|
|
'user_id': user_id,
|
|
'user_name': user_name,
|
|
'user_color': user_color,
|
|
},
|
|
room=f'doc_{document_id}',
|
|
skip_sid=sid,
|
|
)
|
|
|
|
log.info(f'User {user_id} successfully joined document {document_id}')
|
|
|
|
except Exception as e:
|
|
log.error(f'Error in yjs_document_join: {e}')
|
|
await sio.emit('error', {'message': 'Failed to join document'}, room=sid)
|
|
|
|
|
|
async def document_save_handler(document_id, data, user):
|
|
document_id = normalize_document_id(document_id)
|
|
|
|
if document_id.startswith('note:'):
|
|
note_id = document_id.split(':')[1]
|
|
note = await Notes.get_note_by_id(note_id)
|
|
if not note:
|
|
log.error(f'Note {note_id} not found')
|
|
return
|
|
|
|
if (
|
|
user.get('role') != 'admin'
|
|
and user.get('id') != note.user_id
|
|
and not await AccessGrants.has_access(
|
|
user_id=user.get('id'),
|
|
resource_type='note',
|
|
resource_id=note.id,
|
|
permission='write',
|
|
)
|
|
):
|
|
log.error(f'User {user.get("id")} does not have write access to note {note_id}')
|
|
return
|
|
|
|
await Notes.update_note_by_id(note_id, NoteUpdateForm(data=data))
|
|
|
|
|
|
@sio.on('ydoc:document:state')
|
|
async def yjs_document_state(sid, data):
|
|
"""Send the current state of the Yjs document to the user"""
|
|
try:
|
|
document_id = data['document_id']
|
|
|
|
document_id = normalize_document_id(document_id)
|
|
room = f'doc_{document_id}'
|
|
|
|
active_session_ids = get_session_ids_from_room(room)
|
|
|
|
if sid not in active_session_ids:
|
|
log.warning(f'Session {sid} not in room {room}. Cannot send state.')
|
|
return
|
|
|
|
if not await YDOC_MANAGER.document_exists(document_id):
|
|
log.warning(f'Document {document_id} not found')
|
|
return
|
|
|
|
# Get the Yjs document state
|
|
ydoc = Y.Doc()
|
|
updates = await YDOC_MANAGER.get_updates(document_id)
|
|
for update in updates:
|
|
ydoc.apply_update(bytes(update))
|
|
|
|
# Encode the entire document state as an update
|
|
state_update = ydoc.get_update()
|
|
|
|
await sio.emit(
|
|
'ydoc:document:state',
|
|
{
|
|
'document_id': document_id,
|
|
'state': list(state_update), # Convert bytes to list for JSON
|
|
'sessions': active_session_ids,
|
|
},
|
|
room=sid,
|
|
)
|
|
except Exception as e:
|
|
log.error(f'Error in yjs_document_state: {e}')
|
|
|
|
|
|
@sio.on('ydoc:document:update')
|
|
async def yjs_document_update(sid, data):
|
|
"""Handle Yjs document updates"""
|
|
try:
|
|
document_id = data['document_id']
|
|
|
|
document_id = normalize_document_id(document_id)
|
|
|
|
# Verify the sender actually joined this document room
|
|
room = f'doc_{document_id}'
|
|
active_session_ids = get_session_ids_from_room(room)
|
|
if sid not in active_session_ids:
|
|
log.warning(f'Session {sid} not in room {room}. Rejecting update.')
|
|
return
|
|
|
|
try:
|
|
await stop_item_tasks(REDIS, document_id)
|
|
except Exception:
|
|
pass
|
|
|
|
user_id = data.get('user_id', sid)
|
|
|
|
update = data['update'] # List of bytes from frontend
|
|
|
|
await YDOC_MANAGER.append_to_updates(
|
|
document_id=document_id,
|
|
update=update, # Convert list of bytes to bytes
|
|
)
|
|
|
|
# Broadcast update to all other users in the document
|
|
await sio.emit(
|
|
'ydoc:document:update',
|
|
{
|
|
'document_id': document_id,
|
|
'user_id': user_id,
|
|
'update': update,
|
|
'socket_id': sid, # Add socket_id to match frontend filtering
|
|
},
|
|
room=f'doc_{document_id}',
|
|
skip_sid=sid,
|
|
)
|
|
|
|
user = SESSION_POOL.get(sid)
|
|
if not user:
|
|
return
|
|
|
|
async def debounced_save():
|
|
await asyncio.sleep(0.5)
|
|
await document_save_handler(document_id, data.get('data', {}), user)
|
|
|
|
if data.get('data'):
|
|
await create_task(REDIS, debounced_save(), document_id)
|
|
|
|
except Exception as e:
|
|
log.error(f'Error in yjs_document_update: {e}')
|
|
|
|
|
|
@sio.on('ydoc:document:leave')
|
|
async def yjs_document_leave(sid, data):
|
|
"""Handle user leaving a document"""
|
|
try:
|
|
document_id = data['document_id']
|
|
user_id = data.get('user_id', sid)
|
|
|
|
log.info(f'User {user_id} leaving document {document_id}')
|
|
|
|
# Remove user from the document
|
|
await YDOC_MANAGER.remove_user(document_id=document_id, user_id=sid)
|
|
|
|
# Leave Socket.IO room
|
|
await sio.leave_room(sid, f'doc_{document_id}')
|
|
|
|
# Notify other users
|
|
await sio.emit(
|
|
'ydoc:user:left',
|
|
{'document_id': document_id, 'user_id': user_id},
|
|
room=f'doc_{document_id}',
|
|
)
|
|
|
|
if await YDOC_MANAGER.document_exists(document_id) and len(await YDOC_MANAGER.get_users(document_id)) == 0:
|
|
log.info(f'Cleaning up document {document_id} as no users are left')
|
|
await YDOC_MANAGER.clear_document(document_id)
|
|
|
|
except Exception as e:
|
|
log.error(f'Error in yjs_document_leave: {e}')
|
|
|
|
|
|
@sio.on('ydoc:awareness:update')
|
|
async def yjs_awareness_update(sid, data):
|
|
"""Handle awareness updates (cursors, selections, etc.)"""
|
|
try:
|
|
document_id = data['document_id']
|
|
user_id = data.get('user_id', sid)
|
|
update = data['update']
|
|
|
|
# Broadcast awareness update to all other users in the document
|
|
await sio.emit(
|
|
'ydoc:awareness:update',
|
|
{'document_id': document_id, 'user_id': user_id, 'update': update},
|
|
room=f'doc_{document_id}',
|
|
skip_sid=sid,
|
|
)
|
|
|
|
except Exception as e:
|
|
log.error(f'Error in yjs_awareness_update: {e}')
|
|
|
|
|
|
@sio.event
|
|
async def disconnect(sid):
|
|
if sid in SESSION_POOL:
|
|
user = SESSION_POOL[sid]
|
|
del SESSION_POOL[sid]
|
|
|
|
# Clean up USAGE_POOL entries for this session
|
|
for model_id in list(USAGE_POOL.keys()):
|
|
connections = USAGE_POOL.get(model_id)
|
|
if connections and sid in connections:
|
|
del connections[sid]
|
|
if not connections:
|
|
del USAGE_POOL[model_id]
|
|
else:
|
|
USAGE_POOL[model_id] = connections
|
|
|
|
await YDOC_MANAGER.remove_user_from_all_documents(sid)
|
|
else:
|
|
pass
|
|
# print(f"Unknown session ID {sid} disconnected")
|
|
|
|
|
|
async def get_event_emitter(request_info, update_db=True):
|
|
# A single emitter's calls are serial (the streaming loop awaits each
|
|
# before the next), so seq ordering is guaranteed within one emitter.
|
|
# CONCURRENT emitters for the same (user_id, message_id) — e.g. a
|
|
# duplicate request leaking past frontend dedup, or a retry path that
|
|
# overlaps with the original — can interleave INCR/XADD/emit across
|
|
# tasks and cause live frames to arrive out of seq order. Replay
|
|
# reads already sort by seq so resume is safe, but live streaming in
|
|
# that edge case can drop a frame via the client dedupe guard. Fixing
|
|
# it properly needs either a distributed per-message lock or a
|
|
# client-side reorder buffer; neither is worth the complexity for a
|
|
# configuration OWUI doesn't normally produce.
|
|
async def __event_emitter__(event_data):
|
|
user_id = request_info['user_id']
|
|
chat_id = request_info['chat_id']
|
|
message_id = request_info['message_id']
|
|
|
|
seq = await _stream_seq_allocate(user_id, message_id)
|
|
|
|
envelope = {
|
|
'chat_id': chat_id,
|
|
'message_id': message_id,
|
|
'data': event_data,
|
|
}
|
|
# Log before emit so a reconnecting client can't resume-read past
|
|
# a frame that hasn't been persisted yet. Client seq guard drops
|
|
# duplicates from the inverted race.
|
|
if seq is not None:
|
|
envelope['seq'] = seq
|
|
await _stream_log_append(user_id, message_id, envelope, seq)
|
|
await sio.emit('events', envelope, room=f'user:{user_id}')
|
|
|
|
# Any terminal event shortens TTL so log + seq self-evict together.
|
|
# Covers normal completion (done:True), explicit cancel, and
|
|
# errored completions — all end-of-stream flows that the client
|
|
# will no longer replay against.
|
|
outer_type = event_data.get('type') if isinstance(event_data, dict) else None
|
|
inner = event_data.get('data') if isinstance(event_data, dict) else None
|
|
is_terminal = (
|
|
(isinstance(inner, dict) and inner.get('done') is True)
|
|
or outer_type == 'chat:tasks:cancel'
|
|
or (isinstance(inner, dict) and inner.get('error'))
|
|
)
|
|
if is_terminal and REDIS is not None and user_id and message_id:
|
|
try:
|
|
pipe = REDIS.pipeline(transaction=False)
|
|
pipe.expire(_stream_key(user_id, message_id), RESUME_STREAM_DONE_TTL_SEC)
|
|
pipe.expire(_stream_seq_key(user_id, message_id), RESUME_STREAM_DONE_TTL_SEC)
|
|
await asyncio.wait_for(
|
|
pipe.execute(), timeout=RESUME_STREAM_REDIS_TIMEOUT_SEC
|
|
)
|
|
except Exception as e:
|
|
log.warning(f'stream resume log terminal-TTL shorten failed for {message_id}: {e}')
|
|
|
|
if update_db and message_id and not request_info.get('chat_id', '').startswith('local:'):
|
|
event_type = event_data.get('type')
|
|
|
|
if event_type == 'status':
|
|
await Chats.add_message_status_to_chat_by_id_and_message_id(
|
|
request_info['chat_id'],
|
|
request_info['message_id'],
|
|
event_data.get('data', {}),
|
|
)
|
|
|
|
elif event_type == 'message':
|
|
message = await Chats.get_message_by_id_and_message_id(
|
|
request_info['chat_id'],
|
|
request_info['message_id'],
|
|
)
|
|
|
|
if message:
|
|
content = message.get('content', '')
|
|
content += event_data.get('data', {}).get('content', '')
|
|
|
|
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
|
request_info['chat_id'],
|
|
request_info['message_id'],
|
|
{
|
|
'content': content,
|
|
},
|
|
)
|
|
|
|
elif event_type == 'replace':
|
|
content = event_data.get('data', {}).get('content', '')
|
|
|
|
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
|
request_info['chat_id'],
|
|
request_info['message_id'],
|
|
{
|
|
'content': content,
|
|
},
|
|
)
|
|
|
|
elif event_type == 'embeds':
|
|
message = await Chats.get_message_by_id_and_message_id(
|
|
request_info['chat_id'],
|
|
request_info['message_id'],
|
|
)
|
|
|
|
embeds = event_data.get('data', {}).get('embeds', [])
|
|
embeds.extend(message.get('embeds', []))
|
|
|
|
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
|
request_info['chat_id'],
|
|
request_info['message_id'],
|
|
{
|
|
'embeds': embeds,
|
|
},
|
|
)
|
|
|
|
elif event_type == 'files':
|
|
message = await Chats.get_message_by_id_and_message_id(
|
|
request_info['chat_id'],
|
|
request_info['message_id'],
|
|
)
|
|
|
|
files = event_data.get('data', {}).get('files', [])
|
|
files.extend(message.get('files', []))
|
|
|
|
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
|
request_info['chat_id'],
|
|
request_info['message_id'],
|
|
{
|
|
'files': files,
|
|
},
|
|
)
|
|
|
|
elif event_type in ('source', 'citation'):
|
|
data = event_data.get('data', {})
|
|
if data.get('type') is None:
|
|
message = await Chats.get_message_by_id_and_message_id(
|
|
request_info['chat_id'],
|
|
request_info['message_id'],
|
|
)
|
|
|
|
sources = message.get('sources', [])
|
|
sources.append(data)
|
|
|
|
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
|
request_info['chat_id'],
|
|
request_info['message_id'],
|
|
{
|
|
'sources': sources,
|
|
},
|
|
)
|
|
|
|
if 'user_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
|
|
return __event_emitter__
|
|
else:
|
|
return None
|
|
|
|
|
|
async def get_event_call(request_info):
|
|
async def __event_caller__(event_data):
|
|
response = await sio.call(
|
|
'events',
|
|
{
|
|
'chat_id': request_info.get('chat_id', None),
|
|
'message_id': request_info.get('message_id', None),
|
|
'data': event_data,
|
|
},
|
|
to=request_info['session_id'],
|
|
timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT,
|
|
)
|
|
return response
|
|
|
|
if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
|
|
return __event_caller__
|
|
else:
|
|
return None
|
|
|
|
|
|
get_event_caller = get_event_call
|