KeiSeiKit-1.0/_primitives/_rust/kei-notify-sms/src/payload.rs
Parfii-bot a4e667de10 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

113 lines
3.7 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//!
//! SMS body composition: severity-emoji prefix + subject + em-dash +
//! body_text, then UTF-8-safe truncation to 1500 bytes (Twilio's hard
//! per-segment limit is 1600; we keep 100 bytes of headroom).
use kei_runtime_core::traits::notify::{Notification, NotifySeverity};
/// Hard byte cap. Twilio's `Body` parameter accepts up to 1600 chars; we
/// truncate at 1500 BYTES to stay safely below that on any UTF-8 string.
const MAX_BYTES: usize = 1500;
/// Map a [`NotifySeverity`] to a single-glyph prefix. Plain ASCII so the
/// SMS encoding (GSM-7 vs UCS-2) doesn't flip on emoji presence.
pub fn severity_emoji(s: NotifySeverity) -> &'static str {
match s {
NotifySeverity::Info => "[i]",
NotifySeverity::Success => "[+]",
NotifySeverity::Warn => "[!]",
NotifySeverity::Error => "[x]",
}
}
/// Compose the wire body from a `Notification`. Format:
///
/// ```text
/// [<emoji>] <subject> — <body_text>
/// ```
///
/// truncated to 1500 bytes on a UTF-8 character boundary.
pub fn build_body(n: &Notification) -> String {
let prefix = severity_emoji(n.severity);
let raw = format!("{} {}{}", prefix, n.subject, n.body_text);
truncate_utf8(&raw, MAX_BYTES)
}
/// Truncate `s` to at most `max_bytes` bytes without splitting a UTF-8
/// codepoint. Walks back from `max_bytes` to the nearest char boundary.
fn truncate_utf8(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let mut cut = max_bytes;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
s[..cut].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use kei_runtime_core::traits::notify::{Notification, NotifySeverity};
use kei_runtime_core::DnaBuilder;
fn n(sev: NotifySeverity, subject: &str, body: &str) -> Notification {
let dna = DnaBuilder::new("notification")
.cap("NF")
.scope("test")
.body(b"test")
.build()
.unwrap();
let parent = DnaBuilder::new("primitive")
.cap("PR")
.scope("test-parent")
.body(b"parent")
.build()
.unwrap();
Notification {
dna,
parent_dna: parent,
subject: subject.into(),
body_text: body.into(),
body_html: None,
severity: sev,
tags: vec![],
}
}
#[test]
fn warn_emoji() {
let body = build_body(&n(NotifySeverity::Warn, "boot", "ok"));
assert!(body.starts_with("[!]"), "expected [!] prefix, got {body}");
}
#[test]
fn error_emoji() {
let body = build_body(&n(NotifySeverity::Error, "fail", "stack"));
assert!(body.starts_with("[x]"), "expected [x] prefix, got {body}");
}
#[test]
fn truncates_at_1500_bytes() {
let long = "A".repeat(2000);
let out = build_body(&n(NotifySeverity::Warn, "sub", &long));
assert!(out.len() <= 1500, "got {} bytes", out.len());
assert!(out.is_char_boundary(out.len()));
}
#[test]
fn utf8_safe_truncation() {
// Multibyte chars near the boundary must not split. Use 4-byte
// emoji repeated to push the truncation point onto a boundary
// that would otherwise split a codepoint.
let stuffed = "🎯".repeat(500); // 500 * 4 = 2000 bytes
let out = build_body(&n(NotifySeverity::Warn, "x", &stuffed));
assert!(out.len() <= 1500);
// Any prefix produced is a valid UTF-8 string with no replacement
// markers from a mid-codepoint cut.
assert!(!out.contains('\u{FFFD}'));
}
}