feat: 拆分 worker 架构并增强 assistant 安全保护
- 新增 daemon/channel/assistant/function/scheduler/logging worker 架构和 JetStream 消息协议 - 修复邮件通知误入 assistant、迁移 checksum、日志 subject 和工具调用无限循环风险 - 新增 assistant 工具轮次熔断、工具结果截断和提示词软收敛规则 - 补充邮件推送关键字、可信发件人配置、升级日志和验证测试
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "ias-channel"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
ias-common = { path = "../ias-common" }
|
||||
ias-context = { path = "../ias-context" }
|
||||
ias-http = { path = "../ias-http" }
|
||||
ias-mail = { path = "../ias-mail" }
|
||||
ias-wechat = { path = "../ias-wechat" }
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.9.0", features = [
|
||||
"runtime-tokio",
|
||||
"tls-rustls",
|
||||
"postgres",
|
||||
"chrono",
|
||||
] }
|
||||
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
@@ -0,0 +1,602 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use ias_common::mq::{
|
||||
ConsumerSpec, InboundPayload, JetStreamBus, JetStreamConfig, MessageEnvelope,
|
||||
OutboundCommandKind, OutboundCommandPayload, StreamKind, StreamNames, inbound_subject,
|
||||
new_trace_id, outbound_subject,
|
||||
};
|
||||
use ias_context::repository::ContextRepository;
|
||||
use ias_http::web::{internal_url_for_path, public_url_for_path};
|
||||
use ias_mail::repository::MailRepository;
|
||||
use ias_mail::{MailNotification, MailPollerConfig, start_mail_polling};
|
||||
use ias_wechat::manager::{WeChatAccountConfig, WeChatEvent, WeChatMultiAccountManager};
|
||||
use ias_wechat::types::WeixinMessage;
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
type TypingSessions = Arc<Mutex<HashMap<String, String>>>;
|
||||
|
||||
const PROJECT_VERSION: &str = include_str!("../../VERSION");
|
||||
|
||||
fn current_version() -> &'static str {
|
||||
PROJECT_VERSION.trim()
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct WechatAccountRow {
|
||||
account_id: Option<String>,
|
||||
token: Option<String>,
|
||||
base_url: Option<String>,
|
||||
updates_buf: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run_wechat_worker(pool: PgPool, account_ids: Vec<String>) -> anyhow::Result<()> {
|
||||
let config = JetStreamConfig::from_env();
|
||||
loop {
|
||||
match run_wechat_once(pool.clone(), account_ids.clone(), config.clone()).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => {
|
||||
eprintln!("WeChat Worker 运行失败: {error:#}");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_mail_worker(pool: PgPool) -> anyhow::Result<()> {
|
||||
let config = JetStreamConfig::from_env();
|
||||
loop {
|
||||
match run_mail_once(pool.clone(), config.clone()).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => {
|
||||
eprintln!("Mail Worker 运行失败: {error:#}");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_wechat_once(
|
||||
pool: PgPool,
|
||||
account_ids: Vec<String>,
|
||||
config: JetStreamConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let bus = JetStreamBus::connect(config.clone()).await?;
|
||||
bus.ensure_streams().await?;
|
||||
let accounts = load_wechat_accounts(&pool, &account_ids).await?;
|
||||
let (mut manager, event_rx) = WeChatMultiAccountManager::new(1024);
|
||||
for account in accounts {
|
||||
let Some(account_id) = account.account_id else {
|
||||
continue;
|
||||
};
|
||||
manager
|
||||
.start_account(WeChatAccountConfig {
|
||||
token: account.token.unwrap_or_default(),
|
||||
base_url: account.base_url.unwrap_or_default(),
|
||||
account_id,
|
||||
updates_buf: account.updates_buf.unwrap_or_default(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!(error))?;
|
||||
}
|
||||
let manager = Arc::new(manager);
|
||||
let event_task = tokio::spawn(handle_wechat_events(pool.clone(), bus.clone(), event_rx));
|
||||
let startup_notice_task = tokio::spawn(send_startup_notices(pool.clone(), manager.clone()));
|
||||
let typing_sessions: TypingSessions = Arc::new(Mutex::new(HashMap::new()));
|
||||
let names = StreamNames::new(config.stream_prefix.clone());
|
||||
let spec = ConsumerSpec {
|
||||
stream: names.name(StreamKind::Outbound),
|
||||
durable_name: "wechat-worker-outbound".to_string(),
|
||||
filter_subject: "outbound.wechat.>".to_string(),
|
||||
ack_wait: config.ack_wait(),
|
||||
max_deliver: config.max_deliver,
|
||||
};
|
||||
let outbound_bus = bus.clone();
|
||||
let consume_result = bus
|
||||
.consume_envelopes::<OutboundCommandPayload, _, _>(spec, move |envelope| {
|
||||
let manager = manager.clone();
|
||||
let typing_sessions = typing_sessions.clone();
|
||||
let bus = outbound_bus.clone();
|
||||
async move { handle_outbound_command(bus, manager, typing_sessions, envelope).await }
|
||||
})
|
||||
.await;
|
||||
event_task.abort();
|
||||
startup_notice_task.abort();
|
||||
consume_result
|
||||
}
|
||||
|
||||
async fn handle_wechat_events(
|
||||
pool: PgPool,
|
||||
bus: JetStreamBus,
|
||||
mut event_rx: mpsc::Receiver<WeChatEvent>,
|
||||
) {
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
match event {
|
||||
WeChatEvent::UpdatesBuf {
|
||||
account_id,
|
||||
updates_buf,
|
||||
} => {
|
||||
persist_updates_buf(&pool, &account_id, &updates_buf).await;
|
||||
}
|
||||
WeChatEvent::Message {
|
||||
account_id,
|
||||
message,
|
||||
updates_buf,
|
||||
} => {
|
||||
persist_updates_buf(&pool, &account_id, &updates_buf).await;
|
||||
|
||||
if message.msg_type != Some(WeixinMessage::TYPE_USER) {
|
||||
continue;
|
||||
}
|
||||
let Some(from_user_id) = message.from_user_id.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(text) = message.text_content() else {
|
||||
continue;
|
||||
};
|
||||
let payload = InboundPayload {
|
||||
channel: "wechat".to_string(),
|
||||
account_id: account_id.clone(),
|
||||
from_user_id: from_user_id.to_string(),
|
||||
content: text.to_string(),
|
||||
context: message.context_token,
|
||||
external_message_id: message.message_id.map(|id| id.to_string()),
|
||||
session_id: message.session_id,
|
||||
group_id: message.group_id,
|
||||
create_time_ms: message.create_time_ms,
|
||||
};
|
||||
let subject = inbound_subject("wechat", &account_id);
|
||||
let message_id = payload.external_message_id.clone().unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
from_user_id,
|
||||
payload.create_time_ms.unwrap_or_default(),
|
||||
Uuid::new_v4()
|
||||
)
|
||||
});
|
||||
let envelope = MessageEnvelope::new(
|
||||
subject.as_str(),
|
||||
format!("wechat/{account_id}/{message_id}"),
|
||||
new_trace_id(),
|
||||
payload,
|
||||
);
|
||||
if let Err(error) = bus.publish_envelope(subject.as_str(), &envelope).await {
|
||||
eprintln!("发布微信 inbound 消息失败: {error:#}");
|
||||
}
|
||||
}
|
||||
WeChatEvent::PollError { account_id, error } => {
|
||||
eprintln!("微信账号轮询失败 account_id={account_id}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_outbound_command(
|
||||
_bus: JetStreamBus,
|
||||
manager: Arc<WeChatMultiAccountManager>,
|
||||
typing_sessions: TypingSessions,
|
||||
envelope: MessageEnvelope<OutboundCommandPayload>,
|
||||
) -> anyhow::Result<()> {
|
||||
let command = envelope.payload;
|
||||
if command.channel != "wechat" {
|
||||
return Ok(());
|
||||
}
|
||||
let key = typing_key(&command);
|
||||
match command.kind {
|
||||
OutboundCommandKind::Text => {
|
||||
let text = command.content.unwrap_or_default();
|
||||
let client_id = typing_sessions.lock().await.remove(&key);
|
||||
match client_id {
|
||||
Some(client_id) => {
|
||||
manager
|
||||
.send_text_with_client_id(
|
||||
&command.account_id,
|
||||
&command.to_user_id,
|
||||
&text,
|
||||
command.context.as_deref(),
|
||||
&client_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!(error))?;
|
||||
let _ = manager
|
||||
.cancel_typing(
|
||||
&command.account_id,
|
||||
&command.to_user_id,
|
||||
command.context.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
None => {
|
||||
manager
|
||||
.send_text(
|
||||
&command.account_id,
|
||||
&command.to_user_id,
|
||||
&text,
|
||||
command.context.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!(error))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
OutboundCommandKind::TypingStart => {
|
||||
let client_id = manager
|
||||
.send_typing(
|
||||
&command.account_id,
|
||||
&command.to_user_id,
|
||||
command.context.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!(error))?;
|
||||
typing_sessions.lock().await.insert(key, client_id);
|
||||
}
|
||||
OutboundCommandKind::TypingRefresh => {
|
||||
let client_id = typing_sessions.lock().await.get(&key).cloned();
|
||||
if let Some(client_id) = client_id {
|
||||
manager
|
||||
.send_typing_with_client_id(
|
||||
&command.account_id,
|
||||
&command.to_user_id,
|
||||
command.context.as_deref(),
|
||||
&client_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!(error))?;
|
||||
}
|
||||
}
|
||||
OutboundCommandKind::TypingStop => {
|
||||
typing_sessions.lock().await.remove(&key);
|
||||
manager
|
||||
.cancel_typing(
|
||||
&command.account_id,
|
||||
&command.to_user_id,
|
||||
command.context.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!(error))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_mail_once(pool: PgPool, config: JetStreamConfig) -> anyhow::Result<()> {
|
||||
let bus = JetStreamBus::connect(config).await?;
|
||||
bus.ensure_streams().await?;
|
||||
let mut configs = match MailRepository::list_enabled_accounts(&pool).await {
|
||||
Ok(accounts) => accounts
|
||||
.into_iter()
|
||||
.map(MailPollerConfig::from_account)
|
||||
.collect::<Vec<_>>(),
|
||||
Err(error) => {
|
||||
eprintln!("读取数据库邮件账户失败:{error}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if configs.is_empty()
|
||||
&& let Some(config) = MailPollerConfig::from_env()?
|
||||
{
|
||||
configs.push(config);
|
||||
}
|
||||
if configs.is_empty() {
|
||||
println!("Mail Worker 未启动轮询:未配置邮箱账户");
|
||||
}
|
||||
|
||||
let (notification_tx, mut notification_rx) = mpsc::channel::<MailNotification>(64);
|
||||
let mut tasks: Vec<JoinHandle<()>> = configs
|
||||
.into_iter()
|
||||
.map(|config| start_mail_polling(pool.clone(), config, notification_tx.clone()))
|
||||
.collect();
|
||||
|
||||
while let Some(notification) = notification_rx.recv().await {
|
||||
if let Err(error) = publish_mail_notification(&bus, notification).await {
|
||||
eprintln!("发布邮件通知消息失败,继续处理后续通知: {error:#}");
|
||||
}
|
||||
}
|
||||
|
||||
for task in tasks.drain(..) {
|
||||
task.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_mail_notification(
|
||||
bus: &JetStreamBus,
|
||||
notification: MailNotification,
|
||||
) -> anyhow::Result<()> {
|
||||
let trace_id = new_trace_id();
|
||||
let outbound = OutboundCommandPayload::text(
|
||||
"wechat",
|
||||
notification.account_id.clone(),
|
||||
notification.from_user_id.clone(),
|
||||
notification.text.clone(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let outbound_subject = outbound_subject("wechat", ¬ification.account_id);
|
||||
let outbound_envelope = MessageEnvelope::new(
|
||||
outbound_subject.as_str(),
|
||||
outbound.command_id.clone(),
|
||||
trace_id,
|
||||
outbound,
|
||||
);
|
||||
bus.publish_envelope(outbound_subject.as_str(), &outbound_envelope)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_startup_notices(pool: PgPool, manager: Arc<WeChatMultiAccountManager>) {
|
||||
send_online_notices(&pool, manager.as_ref()).await;
|
||||
send_version_update_notice(&pool, manager.as_ref()).await;
|
||||
}
|
||||
|
||||
async fn send_online_notices(pool: &PgPool, manager: &WeChatMultiAccountManager) {
|
||||
if !online_notice_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let text = online_notice_text();
|
||||
let lookback_hours = read_i64_env("IA_ONLINE_NOTICE_LOOKBACK_HOURS", 168);
|
||||
let limit = read_i64_env("IA_ONLINE_NOTICE_LIMIT", 50);
|
||||
let since = Utc::now() - ChronoDuration::hours(lookback_hours);
|
||||
|
||||
for account_id in manager.account_ids() {
|
||||
let recipients =
|
||||
match ContextRepository::recent_recipients(pool, "wechat", &account_id, since, limit)
|
||||
.await
|
||||
{
|
||||
Ok(recipients) => recipients,
|
||||
Err(error) => {
|
||||
eprintln!("查询上线提醒收件人失败 account_id={account_id}: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for recipient in recipients {
|
||||
if let Err(error) = manager
|
||||
.send_text(&account_id, &recipient.from_user_id, &text, None)
|
||||
.await
|
||||
{
|
||||
eprintln!(
|
||||
"发送上线提醒失败 account_id={} to={}: {}",
|
||||
account_id, recipient.from_user_id, error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_version_update_notice(pool: &PgPool, manager: &WeChatMultiAccountManager) {
|
||||
if !version_notice_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let version = current_version();
|
||||
match version_notice_already_sent(pool, version).await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
eprintln!("查询版本更新通知状态失败: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let title = match upgrade_log_title(pool, version).await {
|
||||
Ok(Some(title)) => title,
|
||||
Ok(None) => "服务更新".to_string(),
|
||||
Err(error) => {
|
||||
eprintln!("查询版本更新日志标题失败: {error}");
|
||||
"服务更新".to_string()
|
||||
}
|
||||
};
|
||||
let url = public_url_for_path(&format!("/updates/{version}"));
|
||||
let internal_url = internal_url_for_path(&format!("/updates/{version}"));
|
||||
let text =
|
||||
format!("已更新到 v{version}:{title}\n[公网地址]({url})\n[内网地址]({internal_url})");
|
||||
let lookback_hours = read_i64_env("IA_VERSION_NOTICE_LOOKBACK_HOURS", 168);
|
||||
let limit = read_i64_env("IA_VERSION_NOTICE_LIMIT", 50);
|
||||
let since = Utc::now() - ChronoDuration::hours(lookback_hours);
|
||||
let mut sent_count = 0;
|
||||
|
||||
for account_id in manager.account_ids() {
|
||||
let recipients =
|
||||
match ContextRepository::recent_recipients(pool, "wechat", &account_id, since, limit)
|
||||
.await
|
||||
{
|
||||
Ok(recipients) => recipients,
|
||||
Err(error) => {
|
||||
eprintln!("查询版本更新通知收件人失败 account_id={account_id}: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for recipient in recipients {
|
||||
match manager
|
||||
.send_text(&account_id, &recipient.from_user_id, &text, None)
|
||||
.await
|
||||
{
|
||||
Ok(_) => sent_count += 1,
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"发送版本更新通知失败 account_id={} to={}: {}",
|
||||
account_id, recipient.from_user_id, error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sent_count > 0
|
||||
&& let Err(error) = mark_version_notice_sent(pool, version, sent_count).await
|
||||
{
|
||||
eprintln!("记录版本更新通知状态失败: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn version_notice_already_sent(pool: &PgPool, version: &str) -> anyhow::Result<bool> {
|
||||
let exists = sqlx::query_scalar::<_, i32>(
|
||||
r#"
|
||||
SELECT 1
|
||||
FROM ias_version_notifications
|
||||
WHERE version = $1
|
||||
"#,
|
||||
)
|
||||
.bind(version)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
async fn upgrade_log_title(pool: &PgPool, version: &str) -> anyhow::Result<Option<String>> {
|
||||
let title = sqlx::query_scalar::<_, String>(
|
||||
r#"
|
||||
SELECT title
|
||||
FROM ias_upgrade_logs
|
||||
WHERE version = $1
|
||||
"#,
|
||||
)
|
||||
.bind(version)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
async fn mark_version_notice_sent(
|
||||
pool: &PgPool,
|
||||
version: &str,
|
||||
recipient_count: i32,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO ias_version_notifications (version, recipient_count)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (version) DO UPDATE SET
|
||||
notified_at = NOW(),
|
||||
recipient_count = EXCLUDED.recipient_count
|
||||
"#,
|
||||
)
|
||||
.bind(version)
|
||||
.bind(recipient_count)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn online_notice_enabled() -> bool {
|
||||
read_bool_env("IA_ONLINE_NOTICE_ENABLED", true)
|
||||
}
|
||||
|
||||
fn version_notice_enabled() -> bool {
|
||||
read_bool_env("IA_VERSION_NOTICE_ENABLED", true)
|
||||
}
|
||||
|
||||
fn online_notice_text() -> String {
|
||||
std::env::var("IA_ONLINE_NOTICE_TEXT")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "我又上线啦,现在可以继续帮你处理消息了。".to_string())
|
||||
}
|
||||
|
||||
fn read_bool_env(name: &str, default: bool) -> bool {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|value| {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
!matches!(value.as_str(), "0" | "false" | "no" | "off")
|
||||
})
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn read_i64_env(name: &str, default: i64) -> i64 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<i64>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
async fn load_wechat_accounts(
|
||||
pool: &PgPool,
|
||||
account_ids: &[String],
|
||||
) -> anyhow::Result<Vec<WechatAccountRow>> {
|
||||
if account_ids.is_empty() {
|
||||
return Ok(sqlx::query_as::<_, WechatAccountRow>(
|
||||
r#"
|
||||
SELECT account_id, token, base_url, updates_buf
|
||||
FROM ias_wechat_accounts
|
||||
WHERE account_id IS NOT NULL
|
||||
ORDER BY id ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?);
|
||||
}
|
||||
|
||||
Ok(sqlx::query_as::<_, WechatAccountRow>(
|
||||
r#"
|
||||
SELECT account_id, token, base_url, updates_buf
|
||||
FROM ias_wechat_accounts
|
||||
WHERE account_id = ANY($1)
|
||||
ORDER BY id ASC
|
||||
"#,
|
||||
)
|
||||
.bind(account_ids)
|
||||
.fetch_all(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn persist_updates_buf(pool: &PgPool, account_id: &str, buf: &str) {
|
||||
if let Err(error) = sqlx::query(
|
||||
r#"
|
||||
UPDATE ias_wechat_accounts
|
||||
SET updates_buf = $1
|
||||
WHERE account_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(buf)
|
||||
.bind(account_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
eprintln!("持久化微信 updates_buf 失败 account_id={account_id}: {error:#}");
|
||||
}
|
||||
}
|
||||
|
||||
fn typing_key(command: &OutboundCommandPayload) -> String {
|
||||
format!(
|
||||
"{}:{}:{}:{}",
|
||||
command.account_id,
|
||||
command.to_user_id,
|
||||
command.context.as_deref().unwrap_or_default(),
|
||||
command.turn_id.as_deref().unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::typing_key;
|
||||
use ias_common::mq::{OutboundCommandKind, OutboundCommandPayload};
|
||||
|
||||
#[test]
|
||||
fn typing_key_includes_turn_for_idempotent_typing_session() {
|
||||
let command = OutboundCommandPayload::typing(
|
||||
OutboundCommandKind::TypingStart,
|
||||
"wechat",
|
||||
"acct",
|
||||
"user",
|
||||
Some("ctx".to_string()),
|
||||
None,
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(typing_key(&command), "acct:user:ctx:turn-1");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user