How AionUi Manages Sessions Using Composite Keys (userId:chatId) for Per-Chat Isolation
AionUi enforces per-chat conversation isolation by constructing a composite key from the user ID and chat ID, using this ${userId}:${chatId} string as the unique identifier for all in-memory cache entries and database records.
The open-source AionUi framework (iOfficeAI/AionUi) enables AI assistants to operate across multiple instant messaging platforms while maintaining isolated conversation contexts for each user-chat combination. By implementing a composite key strategy that concatenates userId and chatId, the system ensures that a single user can engage in independent AI conversations across different groups, channels, and direct messages without data leakage between contexts.
Composite Key Architecture
The IChannelSession Type Definition
The foundation of per-chat isolation begins with the type definitions in src/channels/types.ts. The IChannelSession interface includes an optional chatId property that identifies the specific channel or group context.
// src/channels/types.ts (lines 33-38)
interface IChannelSession {
id: string;
userId: string;
chatId?: string; // Platform-specific chat identifier
// ... other session properties
}
The database layer mirrors this structure through IChannelSessionRow (lines 42-49), which includes a chat_id column for SQLite persistence. This schema ensures that every session record stores the chat context alongside the user identifier.
SessionManager and Key Construction
The SessionManager class in src/channels/core/SessionManager.ts implements the core composite key logic. It maintains a private Map called activeSessions (line 19) that stores all live sessions keyed by the composite string.
The buildKey method (lines 28-31) generates deterministic keys for all session operations:
// src/channels/core/SessionManager.ts (lines 28-31)
private buildKey(userId: string, chatId?: string): string {
// If a chatId exists (group or channel), combine it with the userId.
// Otherwise fall back to a user-only key for backward compatibility.
return chatId ? `${userId}:${chatId}` : userId;
}
This method creates a unique namespace for each user-chat pair (e.g., "usr-abc123:G12345") while maintaining backward compatibility for direct messages where chatId may be undefined.
Database Synchronization
On application startup, loadActiveSessions() (lines 35-45) reads all persisted rows from the SQLite database, rebuilds the composite keys using buildKey, and populates the in-memory activeSessions map. The database wrapper (getDatabase()) provides methods like upsertChannelSession and deleteChannelSession that operate on rows containing the chat_id column, ensuring consistency between memory and disk.
Session Lifecycle and Isolation Mechanics
Creating Isolated Sessions
When a user initiates a conversation in a new chat context, SessionManager.createSession delegates to createSessionWithConversation (lines 81-108). This method:
- Generates the composite key via
buildKey(user.id, chatId) - Clears any existing session with the same key (lines 86-89) to prevent stale data
- Persists the new session to SQLite using
db.upsertChannelSession(line 105) - Stores the session in the
activeSessionsmap under the composite key (line 108)
import { SessionManager } from '@/channels/core/SessionManager';
import { getDatabase } from '@/process/database';
import type { IChannelUser } from '@/channels/types';
// Telegram user in group "G12345"
const platformUserId = '123456789';
const platformType: PluginType = 'telegram';
const chatId = 'G12345';
// Resolve or create internal user record
const db = getDatabase();
let user = db.getChannelUserByPlatform(platformUserId, platformType).data;
if (!user) {
user = {
id: uuid(),
platformUserId,
platformType,
authorizedAt: Date.now(),
};
db.upsertChannelUser(user);
}
// Create isolated session for this specific user-group pair
const sessionMgr = new SessionManager();
const session = sessionMgr.createSession(user, 'gemini', undefined, chatId);
console.log(`New session: ${session.id}`); // Unique to user+chat combination
Retrieving Sessions by Composite Key
Channel adapters (Telegram, Lark, DingTalk) retrieve the correct session context by calling getSessionByPlatformUser (lines 58-66), which first resolves the internal IChannelUser via the database, then delegates to getSession(userId, chatId?). This lookup uses the same buildKey logic to ensure that requests from different chats resolve to distinct session objects.
async function onTelegramMessage(msg) {
const { from, chat } = msg;
const platformUserId = from.id.toString();
const chatId = chat.id.toString();
const sessionMgr = new SessionManager();
const session = sessionMgr.getSessionByPlatformUser(
platformUserId,
'telegram',
chatId,
);
if (!session) {
// Initialize new session for this chat context
return;
}
// Proceed with isolated conversation context
console.log(`Found session ${session.id} for chat ${chatId}`);
}
Session Cleanup and Expiration
The SessionManager provides several methods for lifecycle management using the composite key:
clearSession(userId, chatId?): Deletes both the database row and map entry identified by the composite keycleanupStaleSessions(maxAgeMs?): Scans theactiveSessionsmap and removes entries whoselastActivityexceeds the threshold, using the composite key for deletionclearAllSessions(): Iterates over the entire map to delete all database rows, useful when global channel settings change
Cross-Platform Implementation
Each platform adapter in src/channels/plugins/* converts native platform events into unified IUnifiedIncomingMessage objects, extracting both platformUserId and chatId from the payload. These adapters forward both identifiers to SessionManager.getSessionByPlatformUser, ensuring that Telegram groups, Lark threads, and DingTalk chats each maintain independent conversation states for the same user. The composite key approach scales uniformly across all supported IM platforms without requiring platform-specific session logic.
Summary
- Composite Key Construction: AionUi generates unique session identifiers using
${userId}:${chatId}, creating isolated namespaces for every user-chat combination across IM platforms. - Dual Storage Strategy: Sessions exist in both an in-memory
Map(activeSessions) and SQLite database rows withchat_idcolumns, ensuring persistence across restarts. - Lifecycle Consistency: All CRUD operations—creation, retrieval, updates, and deletion—route through the
buildKeymethod, guaranteeing that group chats and direct messages never share context. - Platform Agnostic: The
SessionManagerinterface acceptschatIdparameters from all channel adapters, enabling per-chat isolation for Telegram, Lark, DingTalk, and other platforms.
Frequently Asked Questions
How does AionUi prevent conversation history from bleeding between different group chats?
AionUi prevents context bleeding by incorporating the platform-specific chatId into the session key. When SessionManager.buildKey constructs the identifier ${userId}:${chatId}, it creates a unique key for each group or channel. The activeSessions Map stores entries under these composite keys, so a lookup for user "Alice" in "Group A" returns a different session object than "Alice" in "Group B".
What happens when a user sends a direct message without a chatId?
When chatId is undefined (as in one-on-one direct messages), buildKey falls back to returning only the userId string. This backward-compatible approach ensures that direct messages still receive persistent session context while maintaining the same interface as group chat sessions. The database schema accommodates this via the nullable chat_id column in IChannelSessionRow.
How does the system handle session persistence across application restarts?
During initialization, SessionManager.loadActiveSessions() (lines 35-45) queries all records from the SQLite database via getDatabase().getChannelSessions(), reconstructs the composite keys by passing stored userId and chat_id values to buildKey, and repopulates the activeSessions Map. This ensures that conversation contexts survive process restarts without requiring users to re-authenticate or restart conversations.
Which IM platforms support this per-chat isolation mechanism?
The composite key architecture is platform-agnostic and implemented in the core SessionManager rather than individual adapters. All supported platforms—including Telegram, Lark, DingTalk, and future channel implementations—can leverage per-chat isolation by simply extracting the chatId from their respective message payloads and passing it to getSessionByPlatformUser or createSession.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →