# How AionUi Manages Sessions Using Composite Keys (userId:chatId) for Per-Chat Isolation

> Discover how AionUi isolates per chat conversations using composite keys userId:chatId for unique session management in its in-memory cache and database records.

- Repository: [OfficeAI/AionUi](https://github.com/iofficeai/aionui)
- Tags: internals
- Published: 2026-02-19

---

**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`](https://github.com/iOfficeAI/AionUi/blob/main/src/channels/types.ts). The `IChannelSession` interface includes an optional `chatId` property that identifies the specific channel or group context.

```typescript
// 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`](https://github.com/iOfficeAI/AionUi/blob/main/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:

```typescript
// 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:

1. Generates the composite key via `buildKey(user.id, chatId)`
2. Clears any existing session with the same key (lines 86-89) to prevent stale data
3. Persists the new session to SQLite using `db.upsertChannelSession` (line 105)
4. Stores the session in the `activeSessions` map under the composite key (line 108)

```typescript
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.

```typescript
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 key
- **`cleanupStaleSessions(maxAgeMs?)`**: Scans the `activeSessions` map and removes entries whose `lastActivity` exceeds the threshold, using the composite key for deletion
- **`clearAllSessions()`**: 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 with `chat_id` columns, ensuring persistence across restarts.
- **Lifecycle Consistency**: All CRUD operations—creation, retrieval, updates, and deletion—route through the `buildKey` method, guaranteeing that group chats and direct messages never share context.
- **Platform Agnostic**: The `SessionManager` interface accepts `chatId` parameters 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`.