563 lines
19 KiB
Go
563 lines
19 KiB
Go
package tools
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
const (
|
|
ToolNameSendMessage = "isphere_send_message"
|
|
EnvSendMessageAuditPath = "ISPHERE_SEND_AUDIT_PATH"
|
|
EnvSendMessageIdempotencyPath = "ISPHERE_SEND_IDEMPOTENCY_PATH"
|
|
sendMessageConnector = "implatform-sidecar"
|
|
sendMessageConnectorMode = "sandbox-only-shell"
|
|
sendMessagePreviewStage = "preview"
|
|
sendMessageBlockedStage = "blocked"
|
|
sendMessageProductionMode = "production"
|
|
)
|
|
|
|
type SendMessageArgs struct {
|
|
TargetType string `json:"target_type" jsonschema:"target type; direct or group"`
|
|
TargetID string `json:"target_id" jsonschema:"contact or group id returned by iSphere search tools"`
|
|
ContentText string `json:"content_text" jsonschema:"message body to hash and preview; not stored in audit"`
|
|
ContentSHA256 string `json:"content_sha256" jsonschema:"lowercase SHA256 hex of content_text"`
|
|
IdempotencyKey string `json:"idempotency_key" jsonschema:"required idempotency key; stored only as SHA256 in audit"`
|
|
ExecutionMode string `json:"execution_mode,omitempty" jsonschema:"preview or production; production is blocked until sandbox-send gate passes"`
|
|
}
|
|
|
|
type SendMessageAuditSink interface {
|
|
AppendSendMessageAudit(ctx context.Context, event SendMessageAuditEvent) error
|
|
}
|
|
|
|
type SendMessageIdempotencyStore interface {
|
|
ReserveSendMessageIdempotency(ctx context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error)
|
|
}
|
|
|
|
type SendMessageAuditEvent struct {
|
|
Tool string `json:"tool"`
|
|
TargetType string `json:"target_type"`
|
|
TargetID string `json:"target_id"`
|
|
TargetRef string `json:"target_ref"`
|
|
ExecutionMode string `json:"execution_mode"`
|
|
Connector string `json:"connector"`
|
|
ConnectorMode string `json:"connector_mode"`
|
|
ConnectorStage string `json:"connector_stage"`
|
|
ContentSHA256 string `json:"content_sha256"`
|
|
ContentLength int `json:"content_length"`
|
|
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
|
|
SendStatus string `json:"send_status"`
|
|
Result string `json:"result"`
|
|
AckRef string `json:"ack_ref,omitempty"`
|
|
ErrorCode string `json:"error_code,omitempty"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
AuditRef string `json:"audit_ref"`
|
|
StartedAt string `json:"started_at"`
|
|
FinishedAt string `json:"finished_at"`
|
|
SideEffects map[string]any `json:"side_effects"`
|
|
ContentText string `json:"-"`
|
|
IdempotencyKey string `json:"-"`
|
|
}
|
|
|
|
type SendMessageIdempotencyRecord struct {
|
|
Tool string `json:"tool"`
|
|
TargetRef string `json:"target_ref"`
|
|
ExecutionMode string `json:"execution_mode"`
|
|
Connector string `json:"connector"`
|
|
ConnectorStage string `json:"connector_stage"`
|
|
ContentSHA256 string `json:"content_sha256"`
|
|
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
|
|
AuditRef string `json:"audit_ref"`
|
|
StartedAt string `json:"started_at"`
|
|
}
|
|
|
|
type SendMessageIdempotencyDecision struct {
|
|
Duplicate bool
|
|
Conflict bool
|
|
FirstAuditRef string
|
|
FirstStartedAt string
|
|
}
|
|
|
|
type fileSendMessageAuditSink struct {
|
|
path string
|
|
}
|
|
|
|
type fileSendMessageIdempotencyStore struct {
|
|
path string
|
|
}
|
|
|
|
var sendMessageIdempotencyFileMu sync.Mutex
|
|
|
|
func RegisterISphereSendMessageTool(server *mcp.Server, auditSink SendMessageAuditSink) {
|
|
RegisterISphereSendMessageToolWithState(server, auditSink, nil)
|
|
}
|
|
|
|
func RegisterISphereSendMessageToolWithState(server *mcp.Server, auditSink SendMessageAuditSink, idempotencyStore SendMessageIdempotencyStore) {
|
|
if auditSink == nil {
|
|
auditSink = defaultSendMessageAuditSink()
|
|
}
|
|
if idempotencyStore == nil {
|
|
idempotencyStore = defaultSendMessageIdempotencyStore()
|
|
}
|
|
RegisterISphereSendMessageToolWithStateAndConnector(server, auditSink, idempotencyStore, nil)
|
|
}
|
|
|
|
func RegisterISphereSendMessageToolWithStateAndConnector(server *mcp.Server, auditSink SendMessageAuditSink, idempotencyStore SendMessageIdempotencyStore, connector SendMessageConnector) {
|
|
if auditSink == nil {
|
|
auditSink = defaultSendMessageAuditSink()
|
|
}
|
|
if idempotencyStore == nil {
|
|
idempotencyStore = defaultSendMessageIdempotencyStore()
|
|
}
|
|
|
|
mcp.AddTool[SendMessageArgs, map[string]any](server, &mcp.Tool{
|
|
Name: ToolNameSendMessage,
|
|
Description: "Preview iSphere message sends with idempotency/audit metadata; production send is blocked until sandbox evidence passes.",
|
|
}, func(ctx context.Context, _ *mcp.CallToolRequest, input SendMessageArgs) (*mcp.CallToolResult, map[string]any, error) {
|
|
started := time.Now().UTC()
|
|
normalized, err := normalizeSendMessageArgs(input)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
finished := time.Now().UTC()
|
|
response, event := sendMessagePreviewResponse(normalized, started, finished, connector != nil)
|
|
decision, err := idempotencyStore.ReserveSendMessageIdempotency(ctx, sendMessageIdempotencyRecordFromAuditEvent(event))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendMessage, err)
|
|
}
|
|
applySendMessageIdempotencyDecision(response, &event, decision)
|
|
if normalized.ExecutionMode == sendMessageProductionMode && connector != nil && !decision.Duplicate && !decision.Conflict {
|
|
result, connectorErr := connector.ExecuteSendMessage(ctx, sendMessageConnectorRequestFromNormalized(normalized))
|
|
applySendMessageConnectorResult(response, &event, result, connectorErr)
|
|
}
|
|
if err := auditSink.AppendSendMessageAudit(ctx, event); err != nil {
|
|
return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendMessage, err)
|
|
}
|
|
return nil, response, nil
|
|
})
|
|
}
|
|
|
|
func defaultSendMessageAuditSink() SendMessageAuditSink {
|
|
if path := strings.TrimSpace(os.Getenv(EnvSendMessageAuditPath)); path != "" {
|
|
return fileSendMessageAuditSink{path: path}
|
|
}
|
|
return fileSendMessageAuditSink{path: filepath.Join("runs", "send-audit", "send-message-preview.jsonl")}
|
|
}
|
|
|
|
func defaultSendMessageIdempotencyStore() SendMessageIdempotencyStore {
|
|
if path := strings.TrimSpace(os.Getenv(EnvSendMessageIdempotencyPath)); path != "" {
|
|
return fileSendMessageIdempotencyStore{path: path}
|
|
}
|
|
return fileSendMessageIdempotencyStore{path: filepath.Join("runs", "send-audit", "send-message-idempotency.jsonl")}
|
|
}
|
|
|
|
func (s fileSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
|
|
if strings.TrimSpace(s.path) == "" {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
payload, err := json.Marshal(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
if _, err := file.Write(append(payload, '\n')); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s fileSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error) {
|
|
if strings.TrimSpace(s.path) == "" {
|
|
return SendMessageIdempotencyDecision{}, nil
|
|
}
|
|
if record.IdempotencyKeySHA256 == "" {
|
|
return SendMessageIdempotencyDecision{}, fmt.Errorf("idempotency_key_sha256 is required")
|
|
}
|
|
sendMessageIdempotencyFileMu.Lock()
|
|
defer sendMessageIdempotencyFileMu.Unlock()
|
|
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
|
return SendMessageIdempotencyDecision{}, err
|
|
}
|
|
existing, err := readSendMessageIdempotencyRecords(s.path)
|
|
if err != nil {
|
|
return SendMessageIdempotencyDecision{}, err
|
|
}
|
|
for _, candidate := range existing {
|
|
if candidate.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 {
|
|
continue
|
|
}
|
|
decision := SendMessageIdempotencyDecision{
|
|
FirstAuditRef: candidate.AuditRef,
|
|
FirstStartedAt: candidate.StartedAt,
|
|
}
|
|
if candidate.TargetRef == record.TargetRef && candidate.ContentSHA256 == record.ContentSHA256 {
|
|
decision.Duplicate = true
|
|
return decision, nil
|
|
}
|
|
decision.Conflict = true
|
|
return decision, nil
|
|
}
|
|
|
|
payload, err := json.Marshal(record)
|
|
if err != nil {
|
|
return SendMessageIdempotencyDecision{}, err
|
|
}
|
|
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return SendMessageIdempotencyDecision{}, err
|
|
}
|
|
defer file.Close()
|
|
if _, err := file.Write(append(payload, '\n')); err != nil {
|
|
return SendMessageIdempotencyDecision{}, err
|
|
}
|
|
return SendMessageIdempotencyDecision{}, nil
|
|
}
|
|
|
|
func readSendMessageIdempotencyRecords(path string) ([]SendMessageIdempotencyRecord, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
|
|
var records []SendMessageIdempotencyRecord
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var record SendMessageIdempotencyRecord
|
|
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
|
return nil, fmt.Errorf("decode idempotency record: %w", err)
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
type normalizedSendMessageArgs struct {
|
|
TargetType string
|
|
TargetID string
|
|
TargetRef string
|
|
ContentLength int
|
|
ContentSHA256 string
|
|
IdempotencyKeySHA256 string
|
|
ExecutionMode string
|
|
}
|
|
|
|
func normalizeSendMessageArgs(input SendMessageArgs) (normalizedSendMessageArgs, error) {
|
|
targetType, targetRefPrefix, err := normalizeSendMessageTargetType(input.TargetType)
|
|
if err != nil {
|
|
return normalizedSendMessageArgs{}, err
|
|
}
|
|
targetID := strings.TrimSpace(input.TargetID)
|
|
if targetID == "" {
|
|
return normalizedSendMessageArgs{}, fmt.Errorf("%s target_id is required", ToolNameSendMessage)
|
|
}
|
|
contentText := input.ContentText
|
|
if strings.TrimSpace(contentText) == "" {
|
|
return normalizedSendMessageArgs{}, fmt.Errorf("%s content_text is required", ToolNameSendMessage)
|
|
}
|
|
contentHash, err := validateSendMessageContentHash(contentText, input.ContentSHA256)
|
|
if err != nil {
|
|
return normalizedSendMessageArgs{}, err
|
|
}
|
|
idempotencyKey := strings.TrimSpace(input.IdempotencyKey)
|
|
if idempotencyKey == "" {
|
|
return normalizedSendMessageArgs{}, fmt.Errorf("%s idempotency_key is required", ToolNameSendMessage)
|
|
}
|
|
mode, err := normalizeSendMessageExecutionMode(input.ExecutionMode)
|
|
if err != nil {
|
|
return normalizedSendMessageArgs{}, err
|
|
}
|
|
return normalizedSendMessageArgs{
|
|
TargetType: targetType,
|
|
TargetID: targetID,
|
|
TargetRef: targetRefPrefix + ":" + targetID,
|
|
ContentLength: len(contentText),
|
|
ContentSHA256: contentHash,
|
|
IdempotencyKeySHA256: sha256Hex(idempotencyKey),
|
|
ExecutionMode: mode,
|
|
}, nil
|
|
}
|
|
|
|
func normalizeSendMessageTargetType(value string) (string, string, error) {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "direct", "contact":
|
|
return "direct", "contact", nil
|
|
case "group", "groupchat":
|
|
return "group", "group", nil
|
|
default:
|
|
return "", "", fmt.Errorf("%s target_type %q is not available; use direct or group", ToolNameSendMessage, value)
|
|
}
|
|
}
|
|
|
|
func validateSendMessageContentHash(contentText string, provided string) (string, error) {
|
|
actual := sha256Hex(contentText)
|
|
normalized := strings.ToLower(strings.TrimSpace(provided))
|
|
if normalized == "" {
|
|
return "", fmt.Errorf("%s content_sha256 is required", ToolNameSendMessage)
|
|
}
|
|
if len(normalized) != 64 || !isLowerHex(normalized) {
|
|
return "", fmt.Errorf("%s content_sha256 must be a lowercase SHA256 hex value", ToolNameSendMessage)
|
|
}
|
|
if normalized != actual {
|
|
return "", fmt.Errorf("%s content_sha256 does not match content_text", ToolNameSendMessage)
|
|
}
|
|
return actual, nil
|
|
}
|
|
|
|
func normalizeSendMessageExecutionMode(value string) (string, error) {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "", "preview", "dry_run", "dry-run":
|
|
return "preview", nil
|
|
case sendMessageProductionMode:
|
|
return sendMessageProductionMode, nil
|
|
default:
|
|
return "", fmt.Errorf("%s execution_mode %q is not available; use preview or production", ToolNameSendMessage, value)
|
|
}
|
|
}
|
|
|
|
func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Time, finished time.Time, connectorAvailable bool) (map[string]any, SendMessageAuditEvent) {
|
|
sideEffects := sendMessageNoSideEffects()
|
|
status := "planned"
|
|
stage := sendMessagePreviewStage
|
|
ok := true
|
|
blockedReason := any(nil)
|
|
if input.ExecutionMode == sendMessageProductionMode {
|
|
if connectorAvailable {
|
|
stage = sendMessagePreviewStage
|
|
} else {
|
|
ok = false
|
|
status = "blocked"
|
|
stage = sendMessageBlockedStage
|
|
blockedReason = "production send is blocked until dynamic observation, idempotency audit, and one approved sandbox send gate pass"
|
|
}
|
|
}
|
|
auditRef := sendMessageAuditRef(input.ContentSHA256, input.IdempotencyKeySHA256)
|
|
event := SendMessageAuditEvent{
|
|
Tool: ToolNameSendMessage,
|
|
TargetType: input.TargetType,
|
|
TargetID: input.TargetID,
|
|
TargetRef: input.TargetRef,
|
|
ExecutionMode: input.ExecutionMode,
|
|
Connector: sendMessageConnector,
|
|
ConnectorMode: sendMessageConnectorMode,
|
|
ConnectorStage: stage,
|
|
ContentSHA256: input.ContentSHA256,
|
|
ContentLength: input.ContentLength,
|
|
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
|
|
SendStatus: status,
|
|
Result: status,
|
|
AuditRef: auditRef,
|
|
StartedAt: started.Format(time.RFC3339Nano),
|
|
FinishedAt: finished.Format(time.RFC3339Nano),
|
|
SideEffects: sideEffects,
|
|
}
|
|
response := map[string]any{
|
|
"ok": ok,
|
|
"send_status": status,
|
|
"blocked_reason": blockedReason,
|
|
"execution_mode": input.ExecutionMode,
|
|
"connector": sendMessageConnector,
|
|
"connector_mode": sendMessageConnectorMode,
|
|
"connector_stage": stage,
|
|
"production_send_enabled": false,
|
|
"target": map[string]any{
|
|
"target_type": input.TargetType,
|
|
"target_id": input.TargetID,
|
|
"target_ref": input.TargetRef,
|
|
},
|
|
"content": map[string]any{
|
|
"content_sha256": input.ContentSHA256,
|
|
"content_length": input.ContentLength,
|
|
},
|
|
"idempotency": map[string]any{
|
|
"idempotency_key_sha256": input.IdempotencyKeySHA256,
|
|
"duplicate_detected": false,
|
|
"conflict_detected": false,
|
|
"first_audit_ref": nil,
|
|
"first_started_at": nil,
|
|
},
|
|
"side_effects": sideEffects,
|
|
"audit": map[string]any{
|
|
"tool": ToolNameSendMessage,
|
|
"source": sendMessageConnector,
|
|
"execution_mode": input.ExecutionMode,
|
|
"connector": sendMessageConnector,
|
|
"connector_mode": sendMessageConnectorMode,
|
|
"connector_stage": stage,
|
|
"content_sha256": input.ContentSHA256,
|
|
"target_ref": input.TargetRef,
|
|
"audit_ref": auditRef,
|
|
"started_at": started.Format(time.RFC3339Nano),
|
|
"finished_at": finished.Format(time.RFC3339Nano),
|
|
"result": status,
|
|
},
|
|
}
|
|
return response, event
|
|
}
|
|
|
|
func applySendMessageConnectorResult(response map[string]any, event *SendMessageAuditEvent, result SendMessageConnectorResult, err error) {
|
|
normalized := normalizeSendMessageConnectorResult(result, err)
|
|
response["ok"] = normalized.Accepted
|
|
response["send_status"] = normalized.Status
|
|
response["connector_mode"] = normalized.ConnectorMode
|
|
response["connector_stage"] = normalized.Status
|
|
if normalized.Accepted {
|
|
response["blocked_reason"] = nil
|
|
} else if normalized.ErrorMessage != "" {
|
|
response["blocked_reason"] = normalized.ErrorMessage
|
|
}
|
|
if normalized.AckRef != "" {
|
|
response["ack_ref"] = normalized.AckRef
|
|
}
|
|
if normalized.ErrorCode != "" {
|
|
response["error_code"] = normalized.ErrorCode
|
|
}
|
|
if normalized.ErrorMessage != "" {
|
|
response["error_message"] = normalized.ErrorMessage
|
|
}
|
|
|
|
audit, _ := response["audit"].(map[string]any)
|
|
if audit != nil {
|
|
audit["result"] = normalized.Status
|
|
audit["connector_mode"] = normalized.ConnectorMode
|
|
audit["connector_stage"] = normalized.Status
|
|
if normalized.AckRef != "" {
|
|
audit["ack_ref"] = normalized.AckRef
|
|
}
|
|
if normalized.ErrorCode != "" {
|
|
audit["error_code"] = normalized.ErrorCode
|
|
}
|
|
if normalized.ErrorMessage != "" {
|
|
audit["error_message"] = normalized.ErrorMessage
|
|
}
|
|
}
|
|
|
|
if event != nil {
|
|
event.ConnectorMode = normalized.ConnectorMode
|
|
event.ConnectorStage = normalized.Status
|
|
event.SendStatus = normalized.Status
|
|
event.Result = normalized.Status
|
|
event.AckRef = normalized.AckRef
|
|
event.ErrorCode = normalized.ErrorCode
|
|
event.ErrorMessage = normalized.ErrorMessage
|
|
}
|
|
}
|
|
|
|
func sendMessageIdempotencyRecordFromAuditEvent(event SendMessageAuditEvent) SendMessageIdempotencyRecord {
|
|
return SendMessageIdempotencyRecord{
|
|
Tool: event.Tool,
|
|
TargetRef: event.TargetRef,
|
|
ExecutionMode: event.ExecutionMode,
|
|
Connector: event.Connector,
|
|
ConnectorStage: event.ConnectorStage,
|
|
ContentSHA256: event.ContentSHA256,
|
|
IdempotencyKeySHA256: event.IdempotencyKeySHA256,
|
|
AuditRef: event.AuditRef,
|
|
StartedAt: event.StartedAt,
|
|
}
|
|
}
|
|
|
|
func applySendMessageIdempotencyDecision(response map[string]any, event *SendMessageAuditEvent, decision SendMessageIdempotencyDecision) {
|
|
idempotency, _ := response["idempotency"].(map[string]any)
|
|
if idempotency == nil {
|
|
idempotency = map[string]any{}
|
|
response["idempotency"] = idempotency
|
|
}
|
|
idempotency["duplicate_detected"] = decision.Duplicate
|
|
idempotency["conflict_detected"] = decision.Conflict
|
|
if decision.FirstAuditRef != "" {
|
|
idempotency["first_audit_ref"] = decision.FirstAuditRef
|
|
}
|
|
if decision.FirstStartedAt != "" {
|
|
idempotency["first_started_at"] = decision.FirstStartedAt
|
|
}
|
|
if !decision.Conflict {
|
|
return
|
|
}
|
|
|
|
blockedReason := "idempotency key was already used for a different target or content"
|
|
response["ok"] = false
|
|
response["send_status"] = "blocked"
|
|
response["blocked_reason"] = blockedReason
|
|
response["connector_stage"] = sendMessageBlockedStage
|
|
|
|
audit, _ := response["audit"].(map[string]any)
|
|
if audit != nil {
|
|
audit["result"] = "blocked"
|
|
audit["connector_stage"] = sendMessageBlockedStage
|
|
audit["blocked_reason"] = blockedReason
|
|
}
|
|
|
|
if event != nil {
|
|
event.ConnectorStage = sendMessageBlockedStage
|
|
event.SendStatus = "blocked"
|
|
event.Result = "blocked"
|
|
}
|
|
}
|
|
|
|
func sendMessageNoSideEffects() map[string]any {
|
|
return map[string]any{
|
|
"sent_message": false,
|
|
"sent_file": false,
|
|
"uploaded_file": false,
|
|
"clicked_ui": false,
|
|
"typed_text": false,
|
|
"captured_network": false,
|
|
"attached_hook": false,
|
|
"modified_client_data": false,
|
|
}
|
|
}
|
|
|
|
func sendMessageAuditRef(contentSHA256 string, idempotencySHA256 string) string {
|
|
return "send-message:" + shortHash(contentSHA256) + ":" + shortHash(idempotencySHA256)
|
|
}
|
|
|
|
func shortHash(value string) string {
|
|
if len(value) < 12 {
|
|
return value
|
|
}
|
|
return value[:12]
|
|
}
|
|
|
|
func sha256Hex(value string) string {
|
|
sum := sha256.Sum256([]byte(value))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func isLowerHex(value string) bool {
|
|
for _, ch := range value {
|
|
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|