Codex review of 9b86cd63: ChannelBridgeBase ran the DM guard and the channel lookup before parsing "sender: message", but both bridges evaluated `':' in text` first. A non-string text (None, an int) on a DM or unmapped channel used to log "Error handling mesh channel message" with exc_info and now logged a DEBUG skip instead. The split runs first again. The bridge replay now includes None, int, bytes and list texts; it differs from the pre-bridge code on 9b86cd63 and matches it after this commit.
start, stop, on_transport_reconnected, _on_mesh_channel_message (payload copy, DM guard, sender split, profanity filter) and _process_message_queues (retry, backoff, age limit) move to ChannelBridgeBase in modules/service_plugins/channel_bridge_utils.py; the _utils name keeps the service loader from taking the base class for a service. Each bridge keeps what differs through small hooks: its channel lookup (Telegram ignores a leading '#', Discord doesn't), text preparation (Discord formats mentions before the profanity filter, Telegram builds its HTML after it), delivery, rate limit (25 per minute per webhook vs one per second per chat), HTTP session setup and posting, and its own wording for the old-message, retry and processor-error lines through format templates. The requests fallback stays: it still runs when there is no aiohttp session.
Imports the two modules no longer use are gone (copy, contextlib, meshcore's EventType); BaseServicePlugin, censor and contains_profanity stay as re-exports.
Checked by driving both bridges through 48 scripted scenarios (four configs each, async and requests paths, a fake clock, scripted 2xx/429/5xx/timeout/error responses and Discord rate-limit headers, start/reconnect/stop): every post, logger and meshcore call, queue, send time and rate-limit record is identical before and after, apart from bound-method reprs naming ChannelBridgeBase. Eight mutations across the base and both bridges each change the output.