KeiSeiKit-1.0/_primitives/_rust/kei-gateway/src/session_key.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

195 lines
6.2 KiB
Rust

//! Deterministic session-key construction.
//!
//! Port of Hermes `gateway/session.py:build_session_key` (572-637) with the
//! KeiSei addition of an opt-in blake3 hash for keys exceeding a length floor —
//! storage layers can index either form.
use crate::message::{ChatType, Platform, SessionSource};
/// Tunables forwarded from `GatewayConfig`.
///
/// Mirrors Hermes group_sessions_per_user / thread_sessions_per_user toggles.
#[derive(Debug, Clone, Copy)]
pub struct SessionKeyOpts {
/// In group chats, prefix with the participant ID so each user gets an
/// isolated session in the same room.
pub group_per_user: bool,
/// In threads, ALSO isolate per user (Hermes default = false: threads are
/// shared across all participants).
pub thread_per_user: bool,
/// Optional logical agent name. Defaults to `"main"`.
pub agent_name: &'static str,
}
impl Default for SessionKeyOpts {
fn default() -> Self {
Self {
group_per_user: true,
thread_per_user: false,
agent_name: "main",
}
}
}
/// Build a deterministic session key from a [`SessionSource`].
///
/// Format: `agent:<name>:<platform>:<chat_type>[:<chat_id>][:<thread_id>][:<user_id>]`.
///
/// See Hermes session.py:572-637 for the canonical rules.
pub fn build_session_key(source: &SessionSource, opts: SessionKeyOpts) -> String {
let platform = source.platform.as_str();
let agent = opts.agent_name;
if source.chat_type == ChatType::Dm {
return build_dm_key(source, platform, agent);
}
build_group_key(source, platform, agent, opts)
}
/// DM key — chat_id+thread_id+platform-specific normalisation.
fn build_dm_key(source: &SessionSource, platform: &str, agent: &str) -> String {
let dm_chat_id = canonicalise_dm_chat_id(source);
if let Some(cid) = dm_chat_id {
return match &source.thread_id {
Some(tid) => format!("agent:{agent}:{platform}:dm:{cid}:{tid}"),
None => format!("agent:{agent}:{platform}:dm:{cid}"),
};
}
if let Some(tid) = &source.thread_id {
return format!("agent:{agent}:{platform}:dm:{tid}");
}
format!("agent:{agent}:{platform}:dm")
}
/// Group / channel key — supports per-user isolation and thread shaping.
fn build_group_key(
source: &SessionSource,
platform: &str,
agent: &str,
opts: SessionKeyOpts,
) -> String {
let mut parts: Vec<String> = vec![format!("agent:{agent}"), platform.to_string()];
parts.push(source.chat_type.as_str().to_string());
if let Some(cid) = &source.chat_id {
parts.push(cid.clone());
}
if let Some(tid) = &source.thread_id {
parts.push(tid.clone());
}
if should_isolate_user(source, opts) {
if let Some(pid) = canonicalise_participant(source) {
parts.push(pid);
}
}
parts.join(":")
}
/// Threads default to shared sessions; per-user only when explicitly enabled.
fn should_isolate_user(source: &SessionSource, opts: SessionKeyOpts) -> bool {
if source.thread_id.is_some() && !opts.thread_per_user {
return false;
}
opts.group_per_user
}
/// WhatsApp JID/LID canonicalisation — mirrors Hermes
/// `canonical_whatsapp_identifier` (no-op for other platforms).
fn canonicalise_dm_chat_id(source: &SessionSource) -> Option<String> {
let raw = source.chat_id.as_deref()?;
Some(canonicalise(raw, source.platform))
}
fn canonicalise_participant(source: &SessionSource) -> Option<String> {
let raw = source
.user_id_alt
.as_deref()
.or(source.user_id.as_deref())?;
Some(canonicalise(raw, source.platform))
}
fn canonicalise(raw: &str, platform: Platform) -> String {
if platform == Platform::WhatsApp {
// Strip everything past `@` (LID-vs-JID flip safety).
if let Some((user, _)) = raw.split_once('@') {
return user.to_string();
}
}
raw.to_string()
}
/// blake3 hash a key (hex-encoded). Useful for fixed-length DB indices.
pub fn hash_session_key(key: &str) -> String {
blake3::hash(key.as_bytes()).to_hex().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn dm(platform: Platform, chat_id: &str) -> SessionSource {
SessionSource::dm(platform, chat_id)
}
#[test]
fn dm_key_includes_chat_id() {
let s = dm(Platform::Telegram, "12345");
let k = build_session_key(&s, SessionKeyOpts::default());
assert_eq!(k, "agent:main:telegram:dm:12345");
}
#[test]
fn dm_thread_appends_thread_id() {
let mut s = dm(Platform::Telegram, "12345");
s.thread_id = Some("topic7".into());
let k = build_session_key(&s, SessionKeyOpts::default());
assert_eq!(k, "agent:main:telegram:dm:12345:topic7");
}
#[test]
fn whatsapp_dm_strips_at_suffix() {
let s = dm(Platform::WhatsApp, "5511999@s.whatsapp.net");
let k = build_session_key(&s, SessionKeyOpts::default());
assert_eq!(k, "agent:main:whatsapp:dm:5511999");
}
#[test]
fn group_per_user_appends_user_id() {
let s = SessionSource {
platform: Platform::Discord,
chat_type: ChatType::Group,
chat_id: Some("guild-42".into()),
user_id: Some("alice".into()),
user_id_alt: None,
thread_id: None,
};
let k = build_session_key(&s, SessionKeyOpts::default());
assert_eq!(k, "agent:main:discord:group:guild-42:alice");
}
#[test]
fn group_thread_shared_by_default() {
let s = SessionSource {
platform: Platform::Slack,
chat_type: ChatType::Channel,
chat_id: Some("C9".into()),
user_id: Some("u1".into()),
user_id_alt: None,
thread_id: Some("t1".into()),
};
let k = build_session_key(&s, SessionKeyOpts::default());
// thread_per_user=false → user_id NOT appended
assert_eq!(k, "agent:main:slack:channel:C9:t1");
}
#[test]
fn hash_is_deterministic() {
let h1 = hash_session_key("agent:main:telegram:dm:1");
let h2 = hash_session_key("agent:main:telegram:dm:1");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64); // blake3 hex = 64 chars
}
}