sipeed/picoclaw · error · channels.ErrSendFailed

matrix room ID is empty: %w

Error message

matrix room ID is empty: %w

What it means

Thrown by MatrixChannel.Send when the outbound message's ChatID — which for Matrix is the room ID (e.g. !abc:example.org) — is empty after trimming (pkg/channels/matrix/matrix.go:406). It wraps channels.ErrSendFailed, the permanent sentinel: the manager will NOT retry this send. The channel therefore never contacts the homeserver; the error is purely a routing/data problem upstream of Matrix.

Source

Thrown at pkg/channels/matrix/matrix.go:406

	logger.InfoC("matrix", "Crypto helper initialized successfully")
	return nil
}

func markdownToHTML(md string) string {
	extensions := (parser.CommonExtensions | parser.NoEmptyLineBeforeBlock) &^ parser.DefinitionLists
	p := parser.NewWithExtensions(extensions)
	renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.UseXHTML})
	return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer)))
}

func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
	if !c.IsRunning() {
		return nil, channels.ErrNotRunning
	}

	roomID := id.RoomID(strings.TrimSpace(msg.ChatID))
	if roomID == "" {
		return nil, fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed)
	}

	content := strings.TrimSpace(msg.Content)
	if content == "" {
		return nil, nil
	}

	isToolFeedback := outboundMessageIsToolFeedback(msg)
	if isToolFeedback {
		if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, content); handled {
			if err != nil {
				return nil, err
			}
			return []string{msgID}, nil
		}
	}
	trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID)
	if !isToolFeedback {

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Log msg.ChatID at the call site before Send to find who produces empty room IDs
  2. Fix the upstream router/conversation store to always propagate the Matrix room ID from the inbound event into OutboundMessage.ChatID
  3. Guard sends: skip and dead-letter messages whose ChatID trims to empty instead of calling Send
  4. Remember no retry happens: the manager treats ErrSendFailed as terminal

Example fix

// before
 _ = bus.Publish(bus.OutboundMessage{Content: reply})

// after
 if strings.TrimSpace(conv.RoomID) == "" {
 	logger.ErrorC("matrix", "no room ID bound to conversation; dropping reply")
 	return
 }
 _ = bus.Publish(bus.OutboundMessage{ChatID: conv.RoomID, Content: reply})
Defensive patterns

Strategy: validation

Validate before calling

// before Send
if strings.TrimSpace(msg.ChatID) == "" {
	return fmt.Errorf("outbound message has no chat ID; refusing to send: %w", errDeadLetter)
}
ids, err := matrixCh.Send(ctx, msg)

Type guard

func hasMatrixRoomID(msg bus.OutboundMessage) bool {
	id := strings.TrimSpace(msg.ChatID)
	return strings.HasPrefix(id, "!") && strings.Contains(id[idIndexColon(id):], ":")
}

Try / catch

ids, err := matrixCh.Send(ctx, msg)
if err != nil {
	if errors.Is(err, channels.ErrSendFailed) {
		deadLetter(msg) // permanent: no retry, park for inspection
		return nil
	}
	return err // temporary: manager/outer loop retries
}

Prevention

When it happens

Trigger: bus.OutboundMessage built with an empty or whitespace-only ChatID; upstream conversation router failing to map the inbound event's room to the outgoing message; replying from a context where the original chat ID was never captured (e.g. scheduled/cron-initiated sends with no bound conversation).

Common situations: A new pipeline (webhook, task scheduler, cross-channel relay) emits messages without setting ChatID; refactoring that renames ChatID->RoomID fields and leaves one constructor empty; test fixtures with placeholder strings.

Related errors


AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15). Data as JSON: /api/errors/40ac9212be5bc90b. Report an issue: GitHub.