Add subreddit tone profiles for message style guidance

Introduces a tone-profile system that lets the processor adapt outgoing
message style per-subreddit (and per-flair) instead of using one tone
for everything. Profiles act as a "tone mic" layered after the base
prompt and strategy override, so they shape voice without redefining
the whole message.

- src/config/tone-profiles.ts defines the catalog (default, dominant,
  submissive, playful, direct, ...) and a resolveTone() helper that
  falls back to the default profile for unknown keys.
- config.yaml maps subreddits to tone keys; the schema in
  src/config/index.ts validates it.
- src/services/processor.ts threads the resolved tone into the prompt
  build, picking based on subreddit and optional link flair.
- src/reddit/api.ts adds link_flair_text to RedditPost so the
  processor can use it for tone selection.
- src/ai/openrouter.ts accepts and forwards the tone-shaped prompt.
- public/index.html surfaces the active tone in the dashboard.
- Tests cover the catalog, config validation, and processor integration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.7 committed 2026-05-22 22:29:01 +01:00
1 parent 0c7df6e1d3
commit 46a213e3e0
10 files changed
+497 -51

No files matched your search

+36 -16
View File
@@ -2730,6 +2730,8 @@
? escapeHtml(msg.postTitle.length > 60 ? msg.postTitle.slice(0, 60) + '...' : msg.postTitle)
: '';
const displayRecipient = formatUsernameRaw(msg.recipient);
const imageAnalysis = msg.postImageAnalysis
? `<details class="message-image-analysis">
<summary>Image analysis</summary>
@@ -2740,7 +2742,7 @@
return `
<div class="message-item">
<div class="message-recipient">
u/${escapeHtml(msg.recipient)}
u/${escapeHtml(displayRecipient)}
${msg.gotResponse ? '<span class="response-badge">Got Response!</span>' : ''}
<a href="${postUrl}" target="_blank" style="margin-left:auto; color: var(--ink-soft); font-size: 11px; text-decoration: none; border: 1px solid var(--rule); padding: 3px 9px; border-radius: 999px; font-family: var(--f-mono);">
${sourceLabel} ↗
@@ -2779,6 +2781,19 @@
return div.innerHTML;
}
/* ── Sanitise a username for safe display. t2_* and Matrix IDs are
Reddit platform identifiers — strip the t2_ prefix. Real usernames
do not start with t2_. */
function formatUsernameRaw(username) {
if (!username || username === 'unknown') return 'unknown';
// Strip the full MXID — handles @t2_abc123:reddit.com → t2_abc123
const mxidMatch = username.match(/^@(t2_[^:]+):/);
if (mxidMatch) return mxidMatch[1];
// t2_* is always a Matrix/platform ID — strip the prefix
const t2Match = username.replace(/^t2_/, '');
return t2Match;
}
async function toggleBot() {
const controlBtn = document.getElementById('control-btn');
const isCurrentlyRunning = controlBtn.classList.contains('stop');
@@ -2869,14 +2884,16 @@
container.innerHTML = chats.map(chat => {
const chatUrl = chat.url || 'https://chat.reddit.com';
const safeUsername = escapeHtml(chat.username);
const display = formatUsernameRaw(chat.username);
const displayName = display === 'unknown' ? 'unknown' : 'u/' + escapeHtml(display);
const initial = display.charAt(0).toUpperCase() || '?';
return `
<div class="chat-item ${chat.unread ? 'unread' : ''}" style="cursor: pointer;" onclick="openConversation('${safeUsername}')">
<div class="chat-avatar">${chat.username.charAt(0).toUpperCase()}</div>
<div class="chat-item ${chat.unread ? 'unread' : ''}" style="cursor: pointer;" onclick="openConversation('${escapeHtml(display)}')">
<div class="chat-avatar">${initial}</div>
<div class="chat-content">
<div class="chat-header">
<span class="chat-username">
u/${safeUsername}
${displayName}
<a href="${chatUrl}" target="_blank" rel="noopener" title="Open in Reddit" style="text-decoration: none; margin-left: 4px;" onclick="event.stopPropagation()">↗</a>
${chat.unread ? '<span class="unread-badge">Unread</span>' : ''}
</span>
@@ -3094,13 +3111,12 @@
}
container.innerHTML = items.map(item => {
const username = item.chatUsername || item.sender || null;
const username = formatUsernameRaw(item.chatUsername || item.sender || '');
const timestamp = item.createdAt || item.timestamp || Date.now();
const mediaUrl = item.urlPath || '';
const userLabel = username ? 'u/' + escapeHtml(username) : 'unknown sender';
// Open the conversation modal if we can identify the user; the
// image itself stays click-through to a new tab.
const userClick = username
const rawUser = item.chatUsername || item.sender || '';
const userLabel = rawUser ? 'u/' + escapeHtml(username) : 'unknown sender';
const userClick = rawUser
? `onclick="event.stopPropagation(); openConversation('${escapeHtml(username)}')" style="cursor: pointer; text-decoration: underline;"`
: '';
@@ -3110,7 +3126,7 @@
<img src="${mediaUrl}" alt="Chat media from ${escapeHtml(username || 'unknown')}" loading="lazy" />
</a>
<div class="media-meta">
<span class="media-user" ${userClick} title="${username ? 'Open chat' : 'Recipient unknown'}">${userLabel}</span>
<span class="media-user" ${userClick} title="${rawUser ? 'Open chat' : 'Recipient unknown'}">${userLabel}</span>
<span>${formatTime(timestamp)}</span>
</div>
</div>
@@ -3282,8 +3298,12 @@
}
container.innerHTML = filtered.map(conv => {
const display = formatUsernameRaw(conv.username);
const displayName = display === 'unknown' ? 'unknown' : 'u/' + escapeHtml(display);
const initial = display.charAt(0).toUpperCase() || '?';
const messageCount = conv.messages ? conv.messages.length : 0;
const hasReply = conv.messages ? conv.messages.some(m => !m.isSelf) : false;
const origin = conv.originMessage ? conv.originMessage.slice(0, 60) + '...' : conv.lastMessageSnippet;
let cardClass = 'conversation-card';
@@ -3292,12 +3312,12 @@
const statusBadge = hasReply ? '<span class="reply-indicator">Got Reply!</span>' : '';
return `
<div class="${cardClass}" onclick="openConversation('${escapeHtml(conv.username)}')">
<div class="chat-avatar">${conv.username.charAt(0).toUpperCase()}</div>
<div class="${cardClass}" onclick="openConversation('${escapeHtml(display)}')">
<div class="chat-avatar">${initial}</div>
<div class="chat-content">
<div class="chat-header">
<span class="chat-username">
u/${escapeHtml(conv.username)}
${displayName}
<span class="message-count-badge">${messageCount} msgs</span>
${statusBadge}
</span>
@@ -3343,7 +3363,7 @@
const conv = await res.json();
let metaHtml = '<div class="conversation-meta">';
metaHtml += `<div class="meta-item">Room: <span>${escapeHtml(conv.roomName)}</span></div>`;
metaHtml += `<div class="meta-item">Room: <span>${escapeHtml(formatUsernameRaw(conv.roomName))}</span></div>`;
metaHtml += `<div class="meta-item">Last activity: <span>${formatTime(conv.lastMessageTime)}</span></div>`;
if (conv.url) {
metaHtml += `<div class="meta-item"><a href="${conv.url}" target="_blank" rel="noopener">Open in Reddit ↗</a></div>`;
@@ -3383,7 +3403,7 @@
if (conv.messages && conv.messages.length > 0) {
messagesHtml = conv.messages.map(msg => {
const bubbleClass = msg.isSelf ? 'sent' : 'received';
const senderLabel = msg.isSelf ? 'You' : msg.sender;
const senderLabel = msg.isSelf ? 'You' : escapeHtml(formatUsernameRaw(msg.sender));
const hasMedia = Array.isArray(msg.media) && msg.media.length > 0;
// Suppress the "[Image]" placeholder text when we have the image to render.
const showText = msg.text && !(hasMedia && msg.text.trim() === '[Image]');