From d1e9bb6ae3aa1707781f5613928e81fb00998bbc Mon Sep 17 00:00:00 2001 From: maxiaoyang <2768753269@qq.com> Date: Wed, 29 Apr 2026 20:59:21 +0800 Subject: [PATCH] feat(messageutil): add SanitizeMessageName for OpenAI wire compliance OpenAI's Chat Completions name field requires ^[a-zA-Z0-9_-]{1,64}$. Real-world sender IDs (Discord alice#1234, numeric Telegram IDs, Slack U07AB12C3DEF, free-form CLI names) often violate this, so a sanitizer sits between the raw inbound ID and any wire payload. Disallowed characters collapse into a single underscore, leading and trailing underscores are trimmed, and the result is byte-safely truncated to 64 (output is pure ASCII). This helper will be wired into the agent pipeline in a later commit. Refs #2702. --- pkg/providers/messageutil/messageutil.go | 32 +++++++++ pkg/providers/messageutil/messageutil_test.go | 67 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 pkg/providers/messageutil/messageutil_test.go diff --git a/pkg/providers/messageutil/messageutil.go b/pkg/providers/messageutil/messageutil.go index c4382d894..9ad8ec71f 100644 --- a/pkg/providers/messageutil/messageutil.go +++ b/pkg/providers/messageutil/messageutil.go @@ -1,11 +1,43 @@ package messageutil import ( + "regexp" "strings" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) +// nameSanitizer matches characters that are not valid in the OpenAI Chat +// Completions `name` field. The OpenAI spec requires `^[a-zA-Z0-9_-]{1,64}$`. +var nameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) + +// maxMessageNameLen mirrors the OpenAI Chat Completions hard limit on the +// `name` field. Values longer than this are truncated. +const maxMessageNameLen = 64 + +// SanitizeMessageName normalizes a raw sender identifier into a value safe +// for the OpenAI Chat Completions `name` field and stable across providers. +// Disallowed characters are coalesced into a single underscore; leading and +// trailing underscores are trimmed; the result is truncated to 64 bytes. +// +// Returns "" when raw is empty or collapses to nothing after sanitization. +// The output is pure ASCII so the truncation is byte-safe. +func SanitizeMessageName(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + cleaned := nameSanitizer.ReplaceAllString(raw, "_") + cleaned = strings.Trim(cleaned, "_") + if cleaned == "" { + return "" + } + if len(cleaned) > maxMessageNameLen { + cleaned = cleaned[:maxMessageNameLen] + } + return cleaned +} + // IsTransientAssistantThoughtMessage reports whether msg is an invalid // reasoning-only assistant history record. These "hanging" thought messages // are not a canonical persisted format and should be discarded instead of diff --git a/pkg/providers/messageutil/messageutil_test.go b/pkg/providers/messageutil/messageutil_test.go new file mode 100644 index 000000000..c890da3bb --- /dev/null +++ b/pkg/providers/messageutil/messageutil_test.go @@ -0,0 +1,67 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package messageutil + +import ( + "strings" + "testing" +) + +func TestSanitizeMessageName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"whitespace only", " ", ""}, + {"plain ascii", "alice", "alice"}, + {"discord-style id", "alice#1234", "alice_1234"}, + {"telegram numeric id", "141455495", "141455495"}, + {"slack-style id", "U07AB12C3DEF", "U07AB12C3DEF"}, + {"already valid mixed case", "Alice_Bob-99", "Alice_Bob-99"}, + {"strips leading/trailing underscores", "@alice@", "alice"}, + {"collapses runs", "a@@@b", "a_b"}, + {"non-ascii becomes underscores", "李华", ""}, + {"non-ascii mixed", "alice 李", "alice"}, + {"all special chars collapses to empty", "@@@!!!", ""}, + {"truncates to 64", strings.Repeat("a", 100), strings.Repeat("a", 64)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SanitizeMessageName(tt.in) + if got != tt.want { + t.Errorf("SanitizeMessageName(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestSanitizeMessageName_OutputAlwaysWireSafe(t *testing.T) { + inputs := []string{ + "alice#1234", "U07AB12C3DEF", "141455495", + "@@@", "李华", "alice 李", "Alice_Bob-99", + strings.Repeat("a", 200), + } + for _, in := range inputs { + got := SanitizeMessageName(in) + if got == "" { + continue + } + if len(got) > maxMessageNameLen { + t.Errorf("SanitizeMessageName(%q) length %d exceeds max %d", in, len(got), maxMessageNameLen) + } + for _, r := range got { + ok := (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' + if !ok { + t.Errorf("SanitizeMessageName(%q) = %q contains invalid rune %q", in, got, r) + } + } + } +}