matrix-plugin/session-manager.ts

208 lines
6.2 KiB
TypeScript

import crypto from "crypto"
import fs from "fs"
import path from "path"
interface Session {
matrixRoomId: string
threadRootId: string
opencodeSessionId: string
eventId: string
isActive: boolean
messageCount: number
inputChars: number
outputChars: number
lastActivity: number
lastEventIds: Map<string, string>
}
export function encodeSessionId(matrixRoomId: string, threadRootId: string): string {
const hash = crypto.createHash("md5").update(`${matrixRoomId}:${threadRootId}`).digest("hex").slice(0, 16)
return `ses_${hash}`
}
const SESSION_MAP_FILE = path.join(process.env.HOME || "/tmp", ".opencode-matrix-sessions.json")
console.log(`[SessionManager] SESSION_MAP_FILE=${SESSION_MAP_FILE}`)
interface SessionMapEntry {
matrixRoomId: string
threadRootId: string
opencodeSessionId: string
messageCount: number
inputChars: number
outputChars: number
lastActivity: number
}
function loadSessionMap(): Map<string, SessionMapEntry> {
const result = new Map<string, SessionMapEntry>()
try {
if (fs.existsSync(SESSION_MAP_FILE)) {
const data = JSON.parse(fs.readFileSync(SESSION_MAP_FILE, "utf-8"))
console.log(`[SessionManager] Loaded ${data.length} sessions from ${SESSION_MAP_FILE}`)
if (Array.isArray(data)) {
for (const entry of data) {
const key = `${entry.matrixRoomId}::${entry.threadRootId}`
result.set(key, entry)
}
}
} else {
console.log(`[SessionManager] File not found: ${SESSION_MAP_FILE}`)
}
} catch (e) {
console.log(`[SessionManager] Error loading: ${e}`)
}
return result
}
function saveSessionMap(map: Map<string, SessionMapEntry>): void {
try {
const entries: SessionMapEntry[] = []
for (const [, entry] of map) {
entries.push(entry)
}
fs.writeFileSync(SESSION_MAP_FILE, JSON.stringify(entries, null, 2))
} catch (e) {
// ignore
}
}
export class SessionManager {
public sessions = new Map<string, Session>()
private persistentMap: Map<string, SessionMapEntry>
private activeQueries = new Map<string, () => void>()
private rateLimitMs: number
private lastRequestByUser = new Map<string, number>()
private processedEvents = new Set<string>()
constructor(rateLimitSeconds: number) {
this.rateLimitMs = (rateLimitSeconds || 5) * 1000
this.persistentMap = loadSessionMap()
// Restore sessions from persistent map
for (const [key, entry] of this.persistentMap) {
const [matrixRoomId, threadRootId] = key.split("::")
this.sessions.set(key, {
matrixRoomId,
threadRootId,
opencodeSessionId: entry.opencodeSessionId,
eventId: "",
isActive: false,
messageCount: entry.messageCount,
inputChars: entry.inputChars,
outputChars: entry.outputChars,
lastActivity: entry.lastActivity,
lastEventIds: new Map(),
})
}
}
getSessionKey(matrixRoomId: string, threadRootId: string): string {
return `${matrixRoomId}::${threadRootId}`
}
getOrCreateSession(matrixRoomId: string, threadRootId: string, eventId: string): Session {
const key = this.getSessionKey(matrixRoomId, threadRootId)
let session = this.sessions.get(key)
if (!session) {
session = {
matrixRoomId,
threadRootId,
opencodeSessionId: "",
eventId,
isActive: false,
messageCount: 0,
inputChars: 0,
outputChars: 0,
lastActivity: Date.now(),
lastEventIds: new Map(),
}
this.sessions.set(key, session)
}
return session
}
getSession(matrixRoomId: string, threadRootId: string): Session | undefined {
const key = this.getSessionKey(matrixRoomId, threadRootId)
return this.sessions.get(key)
}
removeSession(matrixRoomId: string, threadRootId: string): void {
const key = this.getSessionKey(matrixRoomId, threadRootId)
this.sessions.delete(key)
this.persistentMap.delete(key)
saveSessionMap(this.persistentMap)
this.activeQueries.delete(key)
}
has(matrixRoomId: string, threadRootId: string): boolean {
const key = this.getSessionKey(matrixRoomId, threadRootId)
return this.sessions.has(key)
}
hasActiveQuery(matrixRoomId: string, threadRootId: string): boolean {
const key = this.getSessionKey(matrixRoomId, threadRootId)
return this.activeQueries.has(key)
}
markQueryActive(matrixRoomId: string, threadRootId: string, abortFn: () => void): () => void {
const key = this.getSessionKey(matrixRoomId, threadRootId)
this.activeQueries.set(key, abortFn)
return () => this.activeQueries.delete(key)
}
isRateLimited(sender: string): boolean {
const now = Date.now()
const last = this.lastRequestByUser.get(sender)
if (last && now - last < this.rateLimitMs) {
return true
}
this.lastRequestByUser.set(sender, now)
return false
}
isDuplicateEvent(eventId: string | undefined): boolean {
if (!eventId) return false
if (this.processedEvents.has(eventId)) return true
if (this.processedEvents.size > 10000) {
this.processedEvents.clear()
}
this.processedEvents.add(eventId)
return false
}
saveSession(matrixRoomId: string, threadRootId: string, opencodeSessionId: string): void {
const key = this.getSessionKey(matrixRoomId, threadRootId)
const session = this.sessions.get(key)
if (session) {
const entry: SessionMapEntry = {
matrixRoomId,
threadRootId,
opencodeSessionId,
messageCount: session.messageCount,
inputChars: session.inputChars,
outputChars: session.outputChars,
lastActivity: session.lastActivity,
}
this.persistentMap.set(key, entry)
saveSessionMap(this.persistentMap)
}
}
syncWithOpenCode(openCodeSessionIds: Set<string>): void {
const toDelete: string[] = []
for (const [, entry] of this.persistentMap) {
if (!openCodeSessionIds.has(entry.opencodeSessionId)) {
toDelete.push(`${entry.matrixRoomId}::${entry.threadRootId}`)
}
}
for (const key of toDelete) {
this.persistentMap.delete(key)
this.sessions.delete(key)
this.activeQueries.delete(key)
}
if (toDelete.length > 0) {
saveSessionMap(this.persistentMap)
}
}
}