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:
1 parent
0c7df6e1d3
commit
46a213e3e0
10 files changed
+497
-51
No files matched your search
+36
-16
@@ -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]');
|
||||
|
||||
Reference in new issue
Block a user