sipeed/picoclaw · error · channels.ErrSendFailed

weixin send: %w: missing context token for chat %s

Error message

weixin send: %w: missing context token for chat %s

What it means

The Weixin channel cannot send a text reply because it has no stored context_token for the target user. The iLink send API requires a context token captured from that user's most recent inbound message (loaded into c.contextTokens on receive); without it any request would be invalid, so the channel refuses locally. The error wraps channels.ErrSendFailed and is explicitly documented in-code as non-temporary so the manager stops retrying.

Source

Thrown at pkg/channels/weixin/weixin.go:424

	}

	// We need a context_token to send a reply. It should be stored in the conversation metadata.
	// The chat_id is the weixin user_id (from_user_id).
	toUserID := msg.ChatID

	// Retrieve context_token from our per-user map (stored on last inbound)
	contextToken := ""
	if ct, ok := c.contextTokens.Load(toUserID); ok {
		contextToken, _ = ct.(string)
	}

	// If we don't have a context token for this user, we cannot send a valid reply.
	// Treat this as a non-temporary error so the manager doesn't keep retrying.
	if contextToken == "" {
		logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{
			"to_user_id": toUserID,
		})
		return nil, fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
	}

	if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil {
		logger.ErrorCF("weixin", "Failed to send message", map[string]any{
			"to_user_id": toUserID,
			"error":      err.Error(),
		})
		if c.remainingPause() > 0 {
			return nil, fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
		}
		return nil, fmt.Errorf("weixin send: %w", channels.ErrTemporary)
	}

	return nil, nil
}

// VoiceCapabilities returns the voice capabilities of the channel.
func (c *WeixinChannel) VoiceCapabilities() channels.VoiceCapabilities {

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Only reply to users who have messaged first — treat an inbound message as the prerequisite for outbound sends
  2. After a channel restart, accept that old conversations are cold: prompt users to send one message to re-arm the token
  3. Verify the toUserID passed to Send exactly matches the user id captured on inbound (no renaming/suffixing in between)
  4. If proactive messaging is required, use a different channel/API that supports it; this Weixin path fundamentally cannot

Example fix

// before
ch.Send(ctx, bus.OutboundMessage{ChatID: userID, Content: "push!"}) // never messaged -> error 674

// after
if _, ok := ch.ContextToken(userID); !ok {
    return errors.New("cannot message user before they message us")
}
ch.Send(ctx, bus.OutboundMessage{ChatID: userID, Content: "reply"})
Defensive patterns

Strategy: validation

Validate before calling

// send only to chats we hold a token for (export ContextToken or check inbound history)
if _, ok := ch.ContextToken(toUserID); !ok {
    return nil // skip: cannot reply to a chat that never messaged us
}
return ch.Send(ctx, msg)

Type guard

func isWeixinMissingContextToken(err error) bool {
    return err != nil && strings.Contains(err.Error(), "missing context token for chat")
}

Try / catch

if _, err := ch.Send(ctx, msg); err != nil {
    if isWeixinMissingContextToken(err) {
        // non-retryable by design: wait for the user's next inbound, then send
        parkUntilInbound(msg)
    }
}

Prevention

When it happens

Trigger: Sending to a toUserID for whom no inbound message has arrived since channel start (proactive/unsolicited message); the token map was reset (channel restart) and the user has not spoken since; a different ID form is used as the destination than the one the token was stored under (raw vs suffixed user id).

Common situations: Bot tries to push a notification before the user ever messaged it; restart cleared in-memory tokens so historical chats can no longer be replied to until the user speaks again; upstream code synthesizes a Send with an internally-mapped ID that mismatches the stored key.

Related errors


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