Add specialized decoders for Meshtastic message types

- Implemented decoders for telemetry, position, node info and waypoint data
- Refactored ServiceEnvelope decoder to use specialized formatters
- Added proper indentation for nested output formatting
- Improved error messages for failed decryption attempts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Daniel Pupius
2025-04-18 20:33:47 -07:00
parent cd648508c1
commit 866f3dc0f2

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/encoding/protojson"
@@ -106,6 +107,498 @@ func IsASCII(data []byte) bool {
return true
}
// FormatTelemetryMessage formats a Telemetry message
func FormatTelemetryMessage(payload []byte) string {
var builder strings.Builder
var telemetry pb.Telemetry
if err := proto.Unmarshal(payload, &telemetry); err != nil {
return fmt.Sprintf("Failed to unmarshal telemetry data: %v\nRaw: %x", err, payload)
}
builder.WriteString("Telemetry Data:\n")
if telemetry.GetTime() != 0 {
tm := time.Unix(int64(telemetry.GetTime()), 0)
builder.WriteString(fmt.Sprintf(" Time: %s\n", tm.Format(time.RFC3339)))
}
// Check which telemetry type we have
if env := telemetry.GetEnvironmentMetrics(); env != nil {
builder.WriteString(" Environment Telemetry:\n")
temp := env.GetTemperature()
if temp != 0 {
builder.WriteString(fmt.Sprintf(" Temperature: %.2f °C\n", temp))
}
humidity := env.GetRelativeHumidity()
if humidity != 0 {
builder.WriteString(fmt.Sprintf(" Humidity: %.2f %%\n", humidity))
}
pressure := env.GetBarometricPressure()
if pressure != 0 {
builder.WriteString(fmt.Sprintf(" Pressure: %.2f hPa\n", pressure))
}
gasRes := env.GetGasResistance()
if gasRes != 0 {
builder.WriteString(fmt.Sprintf(" Gas Resistance: %.2f MΩ\n", gasRes))
}
iaq := env.GetIaq()
if iaq != 0 {
builder.WriteString(fmt.Sprintf(" Air Quality (IAQ): %d\n", iaq))
}
distance := env.GetDistance()
if distance != 0 {
builder.WriteString(fmt.Sprintf(" Distance: %.1f mm\n", distance))
}
lux := env.GetLux()
if lux != 0 {
builder.WriteString(fmt.Sprintf(" Light: %.1f lux\n", lux))
}
windDir := env.GetWindDirection()
if windDir != 0 {
directions := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
dirIndex := int(windDir / 45) % 8
builder.WriteString(fmt.Sprintf(" Wind Direction: %d° (%s)\n", windDir, directions[dirIndex]))
}
windSpeed := env.GetWindSpeed()
if windSpeed != 0 {
builder.WriteString(fmt.Sprintf(" Wind Speed: %.1f m/s\n", windSpeed))
}
windGust := env.GetWindGust()
if windGust != 0 {
builder.WriteString(fmt.Sprintf(" Wind Gust: %.1f m/s\n", windGust))
}
}
if power := telemetry.GetPowerMetrics(); power != nil {
builder.WriteString(" Power Telemetry:\n")
ch1Volt := power.GetCh1Voltage()
if ch1Volt != 0 {
builder.WriteString(fmt.Sprintf(" Channel 1 Voltage: %.2f V\n", ch1Volt))
}
ch1Curr := power.GetCh1Current()
if ch1Curr != 0 {
builder.WriteString(fmt.Sprintf(" Channel 1 Current: %.2f mA\n", ch1Curr))
}
ch2Volt := power.GetCh2Voltage()
if ch2Volt != 0 {
builder.WriteString(fmt.Sprintf(" Channel 2 Voltage: %.2f V\n", ch2Volt))
}
ch2Curr := power.GetCh2Current()
if ch2Curr != 0 {
builder.WriteString(fmt.Sprintf(" Channel 2 Current: %.2f mA\n", ch2Curr))
}
ch3Volt := power.GetCh3Voltage()
if ch3Volt != 0 {
builder.WriteString(fmt.Sprintf(" Channel 3 Voltage: %.2f V\n", ch3Volt))
}
ch3Curr := power.GetCh3Current()
if ch3Curr != 0 {
builder.WriteString(fmt.Sprintf(" Channel 3 Current: %.2f mA\n", ch3Curr))
}
}
if air := telemetry.GetAirQualityMetrics(); air != nil {
builder.WriteString(" Air Quality Telemetry:\n")
pm10 := air.GetPm10Standard()
if pm10 != 0 {
builder.WriteString(fmt.Sprintf(" PM 1.0: %d µg/m³\n", pm10))
}
pm25 := air.GetPm25Standard()
if pm25 != 0 {
builder.WriteString(fmt.Sprintf(" PM 2.5: %d µg/m³\n", pm25))
}
pm100 := air.GetPm100Standard()
if pm100 != 0 {
builder.WriteString(fmt.Sprintf(" PM 10.0: %d µg/m³\n", pm100))
}
co2 := air.GetCo2()
if co2 != 0 {
builder.WriteString(fmt.Sprintf(" CO2: %d ppm\n", co2))
}
// VOC field not available in this version of the proto
}
// Device metrics
if device := telemetry.GetDeviceMetrics(); device != nil {
builder.WriteString(" Device Metrics:\n")
batLevel := device.GetBatteryLevel()
if batLevel != 0 {
builder.WriteString(fmt.Sprintf(" Battery Level: %d%%\n", batLevel))
}
voltage := device.GetVoltage()
if voltage != 0 {
builder.WriteString(fmt.Sprintf(" Voltage: %.2f V\n", voltage))
}
chanUtil := device.GetChannelUtilization()
if chanUtil != 0 {
builder.WriteString(fmt.Sprintf(" Channel Utilization: %.2f%%\n", chanUtil))
}
airUtil := device.GetAirUtilTx()
if airUtil != 0 {
builder.WriteString(fmt.Sprintf(" Air Utilization TX: %.2f%%\n", airUtil))
}
uptime := device.GetUptimeSeconds()
if uptime != 0 {
uptimeDur := time.Duration(uptime) * time.Second
builder.WriteString(fmt.Sprintf(" Uptime: %s\n", uptimeDur))
}
}
// Local stats
if stats := telemetry.GetLocalStats(); stats != nil {
builder.WriteString(" Local Statistics:\n")
if stats.GetUptimeSeconds() != 0 {
uptime := time.Duration(stats.GetUptimeSeconds()) * time.Second
builder.WriteString(fmt.Sprintf(" Uptime: %s\n", uptime))
}
if stats.GetChannelUtilization() != 0 {
builder.WriteString(fmt.Sprintf(" Channel Utilization: %.2f%%\n", stats.GetChannelUtilization()))
}
if stats.GetAirUtilTx() != 0 {
builder.WriteString(fmt.Sprintf(" Air Utilization TX: %.2f%%\n", stats.GetAirUtilTx()))
}
if stats.GetNumPacketsTx() != 0 {
builder.WriteString(fmt.Sprintf(" Packets Transmitted: %d\n", stats.GetNumPacketsTx()))
}
if stats.GetNumPacketsRx() != 0 {
builder.WriteString(fmt.Sprintf(" Packets Received: %d\n", stats.GetNumPacketsRx()))
}
if stats.GetNumPacketsRxBad() != 0 {
builder.WriteString(fmt.Sprintf(" Bad Packets Received: %d\n", stats.GetNumPacketsRxBad()))
}
if stats.GetNumOnlineNodes() != 0 {
builder.WriteString(fmt.Sprintf(" Online Nodes: %d\n", stats.GetNumOnlineNodes()))
}
if stats.GetNumTotalNodes() != 0 {
builder.WriteString(fmt.Sprintf(" Total Nodes: %d\n", stats.GetNumTotalNodes()))
}
}
// Health metrics
if health := telemetry.GetHealthMetrics(); health != nil {
builder.WriteString(" Health Metrics:\n")
heartBpm := health.GetHeartBpm()
if heartBpm != 0 {
builder.WriteString(fmt.Sprintf(" Heart Rate: %d bpm\n", heartBpm))
}
spo2 := health.GetSpO2()
if spo2 != 0 {
builder.WriteString(fmt.Sprintf(" SpO2: %d%%\n", spo2))
}
temp := health.GetTemperature()
if temp != 0 {
builder.WriteString(fmt.Sprintf(" Body Temperature: %.1f °C\n", temp))
}
}
// Marshal to JSON for detailed view
jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(&telemetry)
if err == nil {
builder.WriteString("\nFull Telemetry Structure:\n")
builder.WriteString(string(jsonBytes))
}
return builder.String()
}
// FormatPositionMessage formats a Position message
func FormatPositionMessage(payload []byte) string {
var builder strings.Builder
var position pb.Position
if err := proto.Unmarshal(payload, &position); err != nil {
return fmt.Sprintf("Failed to unmarshal position data: %v\nRaw: %x", err, payload)
}
builder.WriteString("Position Data:\n")
// Check if we have valid position data
if position.GetLatitudeI() != 0 && position.GetLongitudeI() != 0 {
// Convert the integer coordinates to floating-point degrees
// Meshtastic uses a format where the values are stored as integers
// representing the position multiplied by 1e7 (10 million)
lat := float64(position.GetLatitudeI()) / 10000000.0
lon := float64(position.GetLongitudeI()) / 10000000.0
builder.WriteString(fmt.Sprintf(" Latitude: %.7f\n", lat))
builder.WriteString(fmt.Sprintf(" Longitude: %.7f\n", lon))
// Google Maps link for convenience
builder.WriteString(fmt.Sprintf(" Google Maps: https://maps.google.com/?q=%.7f,%.7f\n", lat, lon))
} else {
builder.WriteString(" No valid latitude/longitude data\n")
}
// Add altitude if available
if position.GetAltitude() != 0 {
builder.WriteString(fmt.Sprintf(" Altitude: %d meters\n", position.GetAltitude()))
}
// Add time information
if position.GetTime() != 0 {
builder.WriteString(fmt.Sprintf(" Time: %d\n", position.GetTime()))
}
if position.GetTimestamp() != 0 {
// Convert UNIX timestamp to readable time
tm := time.Unix(int64(position.GetTimestamp()), 0)
builder.WriteString(fmt.Sprintf(" Timestamp: %s\n", tm.Format(time.RFC3339)))
if position.GetTimestampMillisAdjust() != 0 {
builder.WriteString(fmt.Sprintf(" Millis adjustment: %d\n", position.GetTimestampMillisAdjust()))
}
}
// Source info
if position.GetLocationSource() != pb.Position_LOC_UNSET {
builder.WriteString(fmt.Sprintf(" Location Source: %s\n", position.GetLocationSource()))
}
if position.GetAltitudeSource() != pb.Position_ALT_UNSET {
builder.WriteString(fmt.Sprintf(" Altitude Source: %s\n", position.GetAltitudeSource()))
}
// GPS quality information if available
if position.GetPDOP() != 0 {
builder.WriteString(fmt.Sprintf(" PDOP: %.1f\n", float64(position.GetPDOP())/10))
}
if position.GetHDOP() != 0 {
builder.WriteString(fmt.Sprintf(" HDOP: %.1f\n", float64(position.GetHDOP())/10))
}
if position.GetVDOP() != 0 {
builder.WriteString(fmt.Sprintf(" VDOP: %.1f\n", float64(position.GetVDOP())/10))
}
if position.GetGpsAccuracy() != 0 {
builder.WriteString(fmt.Sprintf(" GPS Accuracy: %d meters\n", position.GetGpsAccuracy()))
}
if position.GetGroundSpeed() != 0 {
builder.WriteString(fmt.Sprintf(" Ground Speed: %.1f m/s\n", float64(position.GetGroundSpeed())/100))
}
if position.GetGroundTrack() != 0 {
builder.WriteString(fmt.Sprintf(" Ground Track: %d°\n", position.GetGroundTrack()))
}
if position.GetSatsInView() != 0 {
builder.WriteString(fmt.Sprintf(" Satellites in view: %d\n", position.GetSatsInView()))
}
// Marshal to JSON for detailed view
jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(&position)
if err == nil {
builder.WriteString("\nFull Position Structure:\n")
builder.WriteString(string(jsonBytes))
}
return builder.String()
}
// FormatNodeInfoMessage formats a User message (used by the NODEINFO_APP port)
func FormatNodeInfoMessage(payload []byte) string {
var builder strings.Builder
var user pb.User
if err := proto.Unmarshal(payload, &user); err != nil {
return fmt.Sprintf("Failed to unmarshal node info data: %v\nRaw: %x", err, payload)
}
builder.WriteString("Node Information:\n")
if user.GetId() != "" {
builder.WriteString(fmt.Sprintf(" ID: %s\n", user.GetId()))
}
if user.GetLongName() != "" {
builder.WriteString(fmt.Sprintf(" Name: %s\n", user.GetLongName()))
}
if user.GetShortName() != "" {
builder.WriteString(fmt.Sprintf(" Short Name: %s\n", user.GetShortName()))
}
if user.GetHwModel() != pb.HardwareModel_UNSET {
builder.WriteString(fmt.Sprintf(" Hardware: %s\n", user.GetHwModel()))
}
if user.GetIsLicensed() {
builder.WriteString(" Licensed HAM operator\n")
}
if user.GetRole() != 0 {
builder.WriteString(fmt.Sprintf(" Role: %s\n", user.GetRole()))
}
if len(user.GetPublicKey()) > 0 {
builder.WriteString(fmt.Sprintf(" Public Key: %x\n", user.GetPublicKey()))
}
// Marshal to JSON for detailed view
jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(&user)
if err == nil {
builder.WriteString("\nFull User Structure:\n")
builder.WriteString(string(jsonBytes))
}
return builder.String()
}
// FormatWaypointMessage formats a Waypoint message
func FormatWaypointMessage(payload []byte) string {
var builder strings.Builder
var waypoint pb.Waypoint
if err := proto.Unmarshal(payload, &waypoint); err != nil {
return fmt.Sprintf("Failed to unmarshal waypoint data: %v\nRaw: %x", err, payload)
}
builder.WriteString("Waypoint:\n")
if waypoint.GetId() != 0 {
builder.WriteString(fmt.Sprintf(" ID: %d\n", waypoint.GetId()))
}
if waypoint.GetName() != "" {
builder.WriteString(fmt.Sprintf(" Name: %s\n", waypoint.GetName()))
}
if waypoint.GetDescription() != "" {
builder.WriteString(fmt.Sprintf(" Description: %s\n", waypoint.GetDescription()))
}
if waypoint.GetLatitudeI() != 0 && waypoint.GetLongitudeI() != 0 {
// Convert the integer coordinates to floating-point degrees
lat := float64(waypoint.GetLatitudeI()) / 10000000.0
lon := float64(waypoint.GetLongitudeI()) / 10000000.0
builder.WriteString(fmt.Sprintf(" Latitude: %.7f\n", lat))
builder.WriteString(fmt.Sprintf(" Longitude: %.7f\n", lon))
// Google Maps link for convenience
builder.WriteString(fmt.Sprintf(" Google Maps: https://maps.google.com/?q=%.7f,%.7f\n", lat, lon))
}
if waypoint.GetIcon() != 0 {
builder.WriteString(fmt.Sprintf(" Icon: %d\n", waypoint.GetIcon()))
}
if waypoint.GetExpire() != 0 {
// Convert UNIX timestamp to readable time
tm := time.Unix(int64(waypoint.GetExpire()), 0)
builder.WriteString(fmt.Sprintf(" Expires: %s\n", tm.Format(time.RFC3339)))
}
if waypoint.GetLockedTo() != 0 {
builder.WriteString(fmt.Sprintf(" Locked to node: %d\n", waypoint.GetLockedTo()))
}
// Marshal to JSON for detailed view
jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(&waypoint)
if err == nil {
builder.WriteString("\nFull Waypoint Structure:\n")
builder.WriteString(string(jsonBytes))
}
return builder.String()
}
// FormatData formats a Data message based on its port number
func FormatData(data *pb.Data) string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("Data (Port: %s):\n", data.GetPortnum()))
// Format the payload based on the port number (application type)
switch data.GetPortnum() {
case pb.PortNum_TEXT_MESSAGE_APP:
// Text messages are just plain text
builder.WriteString(fmt.Sprintf(" Text Message: %s\n", string(data.GetPayload())))
case pb.PortNum_TEXT_MESSAGE_COMPRESSED_APP:
// Compressed text - ideally we'd decompress it
builder.WriteString(fmt.Sprintf(" Compressed Text Message (%d bytes): %x\n", len(data.GetPayload()), data.GetPayload()))
// If we had a decompressor: builder.WriteString(fmt.Sprintf(" Decompressed: %s\n", Decompress(data.GetPayload())))
case pb.PortNum_TELEMETRY_APP:
// Telemetry data
builder.WriteString(FormatTelemetryMessage(data.GetPayload()))
case pb.PortNum_POSITION_APP:
// Position data
builder.WriteString(FormatPositionMessage(data.GetPayload()))
case pb.PortNum_NODEINFO_APP:
// Node information
builder.WriteString(FormatNodeInfoMessage(data.GetPayload()))
case pb.PortNum_WAYPOINT_APP:
// Waypoint data
builder.WriteString(FormatWaypointMessage(data.GetPayload()))
default:
// For other message types, just print the payload as hex
if len(data.GetPayload()) > 0 {
builder.WriteString(fmt.Sprintf(" Payload (%d bytes): %x\n", len(data.GetPayload()), data.GetPayload()))
// Try to display as text if it seems to be ASCII
if IsASCII(data.GetPayload()) {
builder.WriteString(fmt.Sprintf(" As text: %s\n", string(data.GetPayload())))
}
} else {
builder.WriteString(" No payload\n")
}
}
// Show additional Data fields
if data.GetRequestId() != 0 {
builder.WriteString(fmt.Sprintf(" Request ID: %d\n", data.GetRequestId()))
}
if data.GetReplyId() != 0 {
builder.WriteString(fmt.Sprintf(" Reply ID: %d\n", data.GetReplyId()))
}
if data.GetEmoji() != 0 {
builder.WriteString(fmt.Sprintf(" Emoji: %d\n", data.GetEmoji()))
}
if data.GetDest() != 0 {
builder.WriteString(fmt.Sprintf(" Destination Node: %d\n", data.GetDest()))
}
if data.GetSource() != 0 {
builder.WriteString(fmt.Sprintf(" Source Node: %d\n", data.GetSource()))
}
if data.GetWantResponse() {
builder.WriteString(" Wants Response: Yes\n")
}
return builder.String()
}
// FormatServiceEnvelope formats a ServiceEnvelope message into a human-readable string
func FormatServiceEnvelope(envelope *pb.ServiceEnvelope) string {
var builder strings.Builder
@@ -150,50 +643,9 @@ func FormatServiceEnvelope(envelope *pb.ServiceEnvelope) string {
// Determine payload type
if packet.GetDecoded() != nil {
data := packet.GetDecoded()
builder.WriteString(fmt.Sprintf("\n Decoded Data (Port: %s):\n", data.GetPortnum()))
// Output portnum-specific information
switch data.GetPortnum() {
case pb.PortNum_TEXT_MESSAGE_APP:
// Text message
builder.WriteString(fmt.Sprintf(" Text Message: %s\n", string(data.GetPayload())))
case pb.PortNum_TELEMETRY_APP:
// Try to decode telemetry data
builder.WriteString(" Telemetry Data\n")
builder.WriteString(fmt.Sprintf(" Payload (%d bytes): %x\n", len(data.GetPayload()), data.GetPayload()))
case pb.PortNum_NODEINFO_APP:
// Node information
builder.WriteString(" Node Information\n")
builder.WriteString(fmt.Sprintf(" Payload (%d bytes): %x\n", len(data.GetPayload()), data.GetPayload()))
case pb.PortNum_POSITION_APP:
// Position data
builder.WriteString(" Position Data\n")
builder.WriteString(fmt.Sprintf(" Payload (%d bytes): %x\n", len(data.GetPayload()), data.GetPayload()))
default:
// For other message types, print the payload as hex
builder.WriteString(fmt.Sprintf(" Payload (%d bytes): %x\n", len(data.GetPayload()), data.GetPayload()))
}
// Show additional Data fields
if data.GetRequestId() != 0 {
builder.WriteString(fmt.Sprintf(" Request ID: %d\n", data.GetRequestId()))
}
if data.GetReplyId() != 0 {
builder.WriteString(fmt.Sprintf(" Reply ID: %d\n", data.GetReplyId()))
}
if data.GetEmoji() != 0 {
builder.WriteString(fmt.Sprintf(" Emoji: %d\n", data.GetEmoji()))
}
if data.GetDest() != 0 {
builder.WriteString(fmt.Sprintf(" Destination Node: %d\n", data.GetDest()))
}
if data.GetSource() != 0 {
builder.WriteString(fmt.Sprintf(" Source Node: %d\n", data.GetSource()))
}
if data.GetWantResponse() {
builder.WriteString(" Wants Response: Yes\n")
}
// For already decoded packets, use our specialized data formatter
builder.WriteString("\n")
builder.WriteString(FormatData(packet.GetDecoded()))
} else if packet.GetEncrypted() != nil {
// Encrypted payload information
@@ -229,54 +681,16 @@ func FormatServiceEnvelope(envelope *pb.ServiceEnvelope) string {
// Try to parse the decrypted payload as a Data message
var data pb.Data
if err := proto.Unmarshal(decrypted, &data); err == nil {
// Successfully decoded the decrypted payload
builder.WriteString(fmt.Sprintf("\n Decoded Data (Port: %s):\n", data.GetPortnum()))
// Output portnum-specific information
switch data.GetPortnum() {
case pb.PortNum_TEXT_MESSAGE_APP:
// Text message
builder.WriteString(fmt.Sprintf(" Text Message: %s\n", string(data.GetPayload())))
case pb.PortNum_TELEMETRY_APP:
// Telemetry data
builder.WriteString(" Telemetry Data\n")
builder.WriteString(fmt.Sprintf(" Payload (%d bytes): %x\n", len(data.GetPayload()), data.GetPayload()))
case pb.PortNum_NODEINFO_APP:
// Node information
builder.WriteString(" Node Information\n")
builder.WriteString(fmt.Sprintf(" Payload (%d bytes): %x\n", len(data.GetPayload()), data.GetPayload()))
case pb.PortNum_POSITION_APP:
// Position data
builder.WriteString(" Position Data\n")
builder.WriteString(fmt.Sprintf(" Payload (%d bytes): %x\n", len(data.GetPayload()), data.GetPayload()))
default:
// For other message types, print the payload as hex
builder.WriteString(fmt.Sprintf(" Payload (%d bytes): %x\n", len(data.GetPayload()), data.GetPayload()))
}
// Show additional Data fields
if data.GetRequestId() != 0 {
builder.WriteString(fmt.Sprintf(" Request ID: %d\n", data.GetRequestId()))
}
if data.GetReplyId() != 0 {
builder.WriteString(fmt.Sprintf(" Reply ID: %d\n", data.GetReplyId()))
}
if data.GetEmoji() != 0 {
builder.WriteString(fmt.Sprintf(" Emoji: %d\n", data.GetEmoji()))
}
if data.GetDest() != 0 {
builder.WriteString(fmt.Sprintf(" Destination Node: %d\n", data.GetDest()))
}
if data.GetSource() != 0 {
builder.WriteString(fmt.Sprintf(" Source Node: %d\n", data.GetSource()))
}
if data.GetWantResponse() {
builder.WriteString(" Wants Response: Yes\n")
}
// Successfully decoded the decrypted payload into a Data message
// Use our specialized data formatter to show the message details
builder.WriteString("\n")
builder.WriteString(indent(FormatData(&data), " "))
} else {
// If we couldn't parse as Data, try to interpret as text
if IsASCII(decrypted) {
builder.WriteString(fmt.Sprintf(" Decrypted as text: %s\n", string(decrypted)))
} else {
builder.WriteString(fmt.Sprintf(" Failed to parse decrypted data as pb.Data: %v\n", err))
}
}
}
@@ -298,6 +712,17 @@ func FormatServiceEnvelope(envelope *pb.ServiceEnvelope) string {
return builder.String()
}
// indent adds indentation to each line of a string
func indent(s, prefix string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
if line != "" {
lines[i] = prefix + line
}
}
return strings.Join(lines, "\n")
}
// FormatJSONMessage formats a JSON message into a human-readable string
func FormatJSONMessage(jsonData map[string]interface{}) string {
var builder strings.Builder