Adds built-in Tlon (Urbit) channel plugin to support decentralized messaging on the Urbit network. Features: - DM and group chat support - SSE-based real-time message monitoring - Auto-discovery of group channels - Thread replies and reactions - Integration with Urbit's HTTP API This resolves cron delivery issues with external Tlon plugins by making it a first-class built-in channel alongside Telegram, Signal, and other messaging platforms. Implementation includes: - Plugin registration via ClawdbotPluginApi - Outbound delivery with sendText and sendMedia - Gateway adapter for inbound message handling - Urbit SSE client for event streaming - Core bridge for Clawdbot runtime integration Co-authored-by: William Arzt <william@arzt.co>
1573 lines
52 KiB
JavaScript
1573 lines
52 KiB
JavaScript
// Polyfill window.location for Node.js environment
|
|
// Required because some clawdbot dependencies (axios, Slack SDK) expect browser globals
|
|
if (typeof global.window === "undefined") {
|
|
global.window = {};
|
|
}
|
|
if (!global.window.location) {
|
|
global.window.location = {
|
|
href: "http://localhost",
|
|
origin: "http://localhost",
|
|
protocol: "http:",
|
|
host: "localhost",
|
|
hostname: "localhost",
|
|
port: "",
|
|
pathname: "/",
|
|
search: "",
|
|
hash: "",
|
|
};
|
|
}
|
|
|
|
import { unixToDa, formatUd } from "@urbit/aura";
|
|
import { UrbitSSEClient } from "./urbit-sse-client.js";
|
|
import { loadCoreChannelDeps } from "./core-bridge.js";
|
|
|
|
console.log("[tlon] ====== monitor.js v2 loaded with action.post.reply structure ======");
|
|
|
|
/**
|
|
* Formats model name for display in signature
|
|
* Converts "anthropic/claude-sonnet-4-5" to "Claude Sonnet 4.5"
|
|
*/
|
|
function formatModelName(modelString) {
|
|
if (!modelString) return "AI";
|
|
|
|
// Remove provider prefix (e.g., "anthropic/", "openai/")
|
|
const modelName = modelString.includes("/")
|
|
? modelString.split("/")[1]
|
|
: modelString;
|
|
|
|
// Convert common model names to friendly format
|
|
const modelMappings = {
|
|
"claude-opus-4-5": "Claude Opus 4.5",
|
|
"claude-sonnet-4-5": "Claude Sonnet 4.5",
|
|
"claude-sonnet-3-5": "Claude Sonnet 3.5",
|
|
"gpt-4o": "GPT-4o",
|
|
"gpt-4-turbo": "GPT-4 Turbo",
|
|
"gpt-4": "GPT-4",
|
|
"gemini-2.0-flash": "Gemini 2.0 Flash",
|
|
"gemini-pro": "Gemini Pro",
|
|
};
|
|
|
|
return modelMappings[modelName] || modelName
|
|
.replace(/-/g, " ")
|
|
.split(" ")
|
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
/**
|
|
* Authenticate and get cookie
|
|
*/
|
|
async function authenticate(url, code) {
|
|
const resp = await fetch(`${url}/~/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: `password=${code}`,
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
throw new Error(`Login failed with status ${resp.status}`);
|
|
}
|
|
|
|
// Read and discard the token body
|
|
await resp.text();
|
|
|
|
// Extract cookie
|
|
const cookie = resp.headers.get("set-cookie");
|
|
if (!cookie) {
|
|
throw new Error("No authentication cookie received");
|
|
}
|
|
|
|
return cookie;
|
|
}
|
|
|
|
/**
|
|
* Sends a direct message via Urbit
|
|
*/
|
|
async function sendDm(api, fromShip, toShip, text) {
|
|
const story = [{ inline: [text] }];
|
|
const sentAt = Date.now();
|
|
const idUd = formatUd(unixToDa(sentAt).toString());
|
|
const id = `${fromShip}/${idUd}`;
|
|
|
|
const delta = {
|
|
add: {
|
|
memo: {
|
|
content: story,
|
|
author: fromShip,
|
|
sent: sentAt,
|
|
},
|
|
kind: null,
|
|
time: null,
|
|
},
|
|
};
|
|
|
|
const action = {
|
|
ship: toShip,
|
|
diff: { id, delta },
|
|
};
|
|
|
|
await api.poke({
|
|
app: "chat",
|
|
mark: "chat-dm-action",
|
|
json: action,
|
|
});
|
|
|
|
return { channel: "tlon", success: true, messageId: id };
|
|
}
|
|
|
|
/**
|
|
* Format a numeric ID with dots every 3 digits (Urbit @ud format)
|
|
* Example: "170141184507780357587090523864791252992" -> "170.141.184.507.780.357.587.090.523.864.791.252.992"
|
|
*/
|
|
function formatUdId(id) {
|
|
if (!id) return id;
|
|
const idStr = String(id);
|
|
// Insert dots every 3 characters from the left
|
|
return idStr.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
|
}
|
|
|
|
/**
|
|
* Sends a message to a group channel
|
|
* @param {string} replyTo - Optional parent post ID for threading
|
|
*/
|
|
async function sendGroupMessage(api, fromShip, hostShip, channelName, text, replyTo = null, runtime = null) {
|
|
const story = [{ inline: [text] }];
|
|
const sentAt = Date.now();
|
|
|
|
// Format reply ID with dots for Urbit @ud format
|
|
const formattedReplyTo = replyTo ? formatUdId(replyTo) : null;
|
|
|
|
const action = {
|
|
channel: {
|
|
nest: `chat/${hostShip}/${channelName}`,
|
|
action: formattedReplyTo ? {
|
|
// Reply action for threading (wraps reply in post like official client)
|
|
post: {
|
|
reply: {
|
|
id: formattedReplyTo,
|
|
action: {
|
|
add: {
|
|
content: story,
|
|
author: fromShip,
|
|
sent: sentAt,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} : {
|
|
// Regular post action
|
|
post: {
|
|
add: {
|
|
content: story,
|
|
author: fromShip,
|
|
sent: sentAt,
|
|
kind: "/chat",
|
|
blob: null,
|
|
meta: null,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
runtime?.log?.(`[tlon] 📤 Sending message: replyTo=${replyTo} (formatted: ${formattedReplyTo}), text="${text.substring(0, 100)}...", nest=chat/${hostShip}/${channelName}`);
|
|
runtime?.log?.(`[tlon] 📤 Action type: ${formattedReplyTo ? 'REPLY (thread)' : 'POST (main channel)'}`);
|
|
runtime?.log?.(`[tlon] 📤 Full action structure: ${JSON.stringify(action, null, 2)}`);
|
|
|
|
try {
|
|
const pokeResult = await api.poke({
|
|
app: "channels",
|
|
mark: "channel-action-1",
|
|
json: action,
|
|
});
|
|
|
|
runtime?.log?.(`[tlon] 📤 Poke succeeded: ${JSON.stringify(pokeResult)}`);
|
|
return { channel: "tlon", success: true, messageId: `${fromShip}/${sentAt}` };
|
|
} catch (error) {
|
|
runtime?.error?.(`[tlon] 📤 Poke FAILED: ${error.message}`);
|
|
runtime?.error?.(`[tlon] 📤 Error details: ${JSON.stringify(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the bot's ship is mentioned in a message
|
|
*/
|
|
function isBotMentioned(messageText, botShipName) {
|
|
if (!messageText || !botShipName) return false;
|
|
|
|
// Normalize bot ship name (ensure it has ~)
|
|
const normalizedBotShip = botShipName.startsWith("~")
|
|
? botShipName
|
|
: `~${botShipName}`;
|
|
|
|
// Escape special regex characters
|
|
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
|
// Check for mention - ship name should be at start, after whitespace, or standalone
|
|
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i");
|
|
return mentionPattern.test(messageText);
|
|
}
|
|
|
|
/**
|
|
* Parses commands related to notebook operations
|
|
* @param {string} messageText - The message to parse
|
|
* @returns {Object|null} Command info or null if no command detected
|
|
*/
|
|
function parseNotebookCommand(messageText) {
|
|
const text = messageText.toLowerCase().trim();
|
|
|
|
// Save to notebook patterns
|
|
const savePatterns = [
|
|
/save (?:this|that) to (?:my )?notes?/i,
|
|
/save to (?:my )?notes?/i,
|
|
/save to notebook/i,
|
|
/add to (?:my )?diary/i,
|
|
/save (?:this|that) to (?:my )?diary/i,
|
|
/save to (?:my )?diary/i,
|
|
/save (?:this|that)/i,
|
|
];
|
|
|
|
for (const pattern of savePatterns) {
|
|
if (pattern.test(text)) {
|
|
return {
|
|
type: "save_to_notebook",
|
|
title: extractTitle(messageText),
|
|
};
|
|
}
|
|
}
|
|
|
|
// List notebook patterns
|
|
const listPatterns = [
|
|
/(?:list|show) (?:my )?(?:notes?|notebook|diary)/i,
|
|
/what(?:'s| is) in (?:my )?(?:notes?|notebook|diary)/i,
|
|
/check (?:my )?(?:notes?|notebook|diary)/i,
|
|
];
|
|
|
|
for (const pattern of listPatterns) {
|
|
if (pattern.test(text)) {
|
|
return {
|
|
type: "list_notebook",
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extracts a title from a save command
|
|
* @param {string} text - The message text
|
|
* @returns {string|null} Extracted title or null
|
|
*/
|
|
function extractTitle(text) {
|
|
// Try to extract title from "as [title]" or "with title [title]"
|
|
const asMatch = /(?:as|with title)\s+["']([^"']+)["']/i.exec(text);
|
|
if (asMatch) return asMatch[1];
|
|
|
|
const asMatch2 = /(?:as|with title)\s+(.+?)(?:\.|$)/i.exec(text);
|
|
if (asMatch2) return asMatch2[1].trim();
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Sends a post to an Urbit diary channel
|
|
* @param {Object} api - Authenticated Urbit API instance
|
|
* @param {Object} account - Account configuration
|
|
* @param {string} diaryChannel - Diary channel in format "diary/~host/channel-id"
|
|
* @param {string} title - Post title
|
|
* @param {string} content - Post content
|
|
* @returns {Promise<{essayId: string, sentAt: number}>}
|
|
*/
|
|
async function sendDiaryPost(api, account, diaryChannel, title, content) {
|
|
// Parse channel format: "diary/~host/channel-id"
|
|
const match = /^diary\/~?([a-z-]+)\/([a-z0-9]+)$/i.exec(diaryChannel);
|
|
|
|
if (!match) {
|
|
throw new Error(`Invalid diary channel format: ${diaryChannel}. Expected: diary/~host/channel-id`);
|
|
}
|
|
|
|
const host = match[1];
|
|
const channelId = match[2];
|
|
const nest = `diary/~${host}/${channelId}`;
|
|
|
|
// Construct essay (diary entry) format
|
|
const sentAt = Date.now();
|
|
const idUd = formatUd(unixToDa(sentAt).toString());
|
|
const fromShip = account.ship.startsWith("~") ? account.ship : `~${account.ship}`;
|
|
const essayId = `${fromShip}/${idUd}`;
|
|
|
|
const action = {
|
|
channel: {
|
|
nest,
|
|
action: {
|
|
post: {
|
|
add: {
|
|
content: [{ inline: [content] }],
|
|
sent: sentAt,
|
|
kind: "/diary",
|
|
author: fromShip,
|
|
blob: null,
|
|
meta: {
|
|
title: title || "Saved Note",
|
|
image: "",
|
|
description: "",
|
|
cover: "",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
await api.poke({
|
|
app: "channels",
|
|
mark: "channel-action-1",
|
|
json: action,
|
|
});
|
|
|
|
return { essayId, sentAt };
|
|
}
|
|
|
|
/**
|
|
* Fetches diary entries from an Urbit diary channel
|
|
* @param {Object} api - Authenticated Urbit API instance
|
|
* @param {string} diaryChannel - Diary channel in format "diary/~host/channel-id"
|
|
* @param {number} limit - Maximum number of entries to fetch (default: 10)
|
|
* @returns {Promise<Array>} Array of diary entries with { id, title, content, author, sent }
|
|
*/
|
|
async function fetchDiaryEntries(api, diaryChannel, limit = 10) {
|
|
// Parse channel format: "diary/~host/channel-id"
|
|
const match = /^diary\/~?([a-z-]+)\/([a-z0-9]+)$/i.exec(diaryChannel);
|
|
|
|
if (!match) {
|
|
throw new Error(`Invalid diary channel format: ${diaryChannel}. Expected: diary/~host/channel-id`);
|
|
}
|
|
|
|
const host = match[1];
|
|
const channelId = match[2];
|
|
const nest = `diary/~${host}/${channelId}`;
|
|
|
|
try {
|
|
// Scry the diary channel for posts
|
|
const response = await api.scry({
|
|
app: "channels",
|
|
path: `/channel/${nest}/posts/newest/${limit}`,
|
|
});
|
|
|
|
if (!response || !response.posts) {
|
|
return [];
|
|
}
|
|
|
|
// Extract and format diary entries
|
|
const entries = Object.entries(response.posts).map(([id, post]) => {
|
|
const essay = post.essay || {};
|
|
|
|
// Extract text content from prose blocks
|
|
let content = "";
|
|
if (essay.content && Array.isArray(essay.content)) {
|
|
content = essay.content
|
|
.map((block) => {
|
|
if (block.block?.prose?.inline) {
|
|
return block.block.prose.inline.join("");
|
|
}
|
|
return "";
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
return {
|
|
id,
|
|
title: essay.title || "Untitled",
|
|
content,
|
|
author: essay.author || "unknown",
|
|
sent: essay.sent || 0,
|
|
};
|
|
});
|
|
|
|
// Sort by sent time (newest first)
|
|
return entries.sort((a, b) => b.sent - a.sent);
|
|
} catch (error) {
|
|
console.error(`[tlon] Error fetching diary entries from ${nest}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a ship is allowed to send DMs to the bot
|
|
*/
|
|
function isDmAllowed(senderShip, account) {
|
|
// If dmAllowlist is not configured or empty, allow all
|
|
if (!account.dmAllowlist || !Array.isArray(account.dmAllowlist) || account.dmAllowlist.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
// Normalize ship names for comparison (ensure ~ prefix)
|
|
const normalizedSender = senderShip.startsWith("~")
|
|
? senderShip
|
|
: `~${senderShip}`;
|
|
|
|
const normalizedAllowlist = account.dmAllowlist
|
|
.map((ship) => ship.startsWith("~") ? ship : `~${ship}`);
|
|
|
|
// Check if sender is in allowlist
|
|
return normalizedAllowlist.includes(normalizedSender);
|
|
}
|
|
|
|
/**
|
|
* Extracts text content from Tlon message structure
|
|
*/
|
|
function extractMessageText(content) {
|
|
if (!content || !Array.isArray(content)) return "";
|
|
|
|
return content
|
|
.map((block) => {
|
|
if (block.inline && Array.isArray(block.inline)) {
|
|
return block.inline
|
|
.map((item) => {
|
|
if (typeof item === "string") return item;
|
|
if (item && typeof item === "object") {
|
|
if (item.ship) return item.ship; // Ship mention
|
|
if (item.break !== undefined) return "\n"; // Line break
|
|
if (item.link && item.link.href) return item.link.href; // URL link
|
|
// Skip other objects (images, etc.)
|
|
}
|
|
return "";
|
|
})
|
|
.join("");
|
|
}
|
|
return "";
|
|
})
|
|
.join("\n")
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Parses a channel nest identifier
|
|
* Format: chat/~host-ship/channel-name
|
|
*/
|
|
function parseChannelNest(nest) {
|
|
if (!nest) return null;
|
|
const parts = nest.split("/");
|
|
if (parts.length !== 3 || parts[0] !== "chat") return null;
|
|
|
|
return {
|
|
hostShip: parts[1],
|
|
channelName: parts[2],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Message cache for channel history (for faster access)
|
|
* Structure: Map<channelNest, Array<{author, content, timestamp, id}>>
|
|
*/
|
|
const messageCache = new Map();
|
|
const MAX_CACHED_MESSAGES = 100;
|
|
|
|
/**
|
|
* Adds a message to the cache
|
|
*/
|
|
function cacheMessage(channelNest, message) {
|
|
if (!messageCache.has(channelNest)) {
|
|
messageCache.set(channelNest, []);
|
|
}
|
|
|
|
const cache = messageCache.get(channelNest);
|
|
cache.unshift(message); // Add to front (most recent)
|
|
|
|
// Keep only last MAX_CACHED_MESSAGES
|
|
if (cache.length > MAX_CACHED_MESSAGES) {
|
|
cache.pop();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches channel history from Urbit via scry
|
|
* Format: /channels/v4/<channel-nest>/posts/newest/<count>/outline.json
|
|
* Returns pagination object: { newest, posts: {...}, total, newer, older }
|
|
*/
|
|
async function fetchChannelHistory(api, channelNest, count = 50, runtime) {
|
|
try {
|
|
const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`;
|
|
runtime?.log?.(`[tlon] Fetching history: ${scryPath}`);
|
|
|
|
const data = await api.scry(scryPath);
|
|
runtime?.log?.(`[tlon] Scry returned data type: ${Array.isArray(data) ? 'array' : typeof data}, keys: ${typeof data === 'object' ? Object.keys(data).slice(0, 5).join(', ') : 'N/A'}`);
|
|
|
|
if (!data) {
|
|
runtime?.log?.(`[tlon] Data is null`);
|
|
return [];
|
|
}
|
|
|
|
// Extract posts from pagination object
|
|
let posts = [];
|
|
if (Array.isArray(data)) {
|
|
// Direct array of posts
|
|
posts = data;
|
|
} else if (data.posts && typeof data.posts === 'object') {
|
|
// Pagination object with posts property (keyed by ID)
|
|
posts = Object.values(data.posts);
|
|
runtime?.log?.(`[tlon] Extracted ${posts.length} posts from pagination object`);
|
|
} else if (typeof data === 'object') {
|
|
// Fallback: treat as keyed object
|
|
posts = Object.values(data);
|
|
}
|
|
|
|
runtime?.log?.(`[tlon] Processing ${posts.length} posts`);
|
|
|
|
// Extract posts from outline format
|
|
const messages = posts.map(item => {
|
|
// Handle both post and r-post structures
|
|
const essay = item.essay || item['r-post']?.set?.essay;
|
|
const seal = item.seal || item['r-post']?.set?.seal;
|
|
|
|
return {
|
|
author: essay?.author || 'unknown',
|
|
content: extractMessageText(essay?.content || []),
|
|
timestamp: essay?.sent || Date.now(),
|
|
id: seal?.id,
|
|
};
|
|
}).filter(msg => msg.content); // Filter out empty messages
|
|
|
|
runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`);
|
|
return messages;
|
|
} catch (error) {
|
|
runtime?.log?.(`[tlon] Error fetching channel history: ${error.message}`);
|
|
console.error(`[tlon] Error fetching channel history: ${error.message}`, error.stack);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets recent channel history (tries cache first, then scry)
|
|
*/
|
|
async function getChannelHistory(api, channelNest, count = 50, runtime) {
|
|
// Try cache first for speed
|
|
const cache = messageCache.get(channelNest) || [];
|
|
if (cache.length >= count) {
|
|
runtime?.log?.(`[tlon] Using cached messages (${cache.length} available)`);
|
|
return cache.slice(0, count);
|
|
}
|
|
|
|
runtime?.log?.(`[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`);
|
|
// Fall back to scry for full history
|
|
return await fetchChannelHistory(api, channelNest, count, runtime);
|
|
}
|
|
|
|
/**
|
|
* Detects if a message is a summarization request
|
|
*/
|
|
function isSummarizationRequest(messageText) {
|
|
const patterns = [
|
|
/summarize\s+(this\s+)?(channel|chat|conversation)/i,
|
|
/what\s+did\s+i\s+miss/i,
|
|
/catch\s+me\s+up/i,
|
|
/channel\s+summary/i,
|
|
/tldr/i,
|
|
];
|
|
return patterns.some(pattern => pattern.test(messageText));
|
|
}
|
|
|
|
/**
|
|
* Formats a date for the groups-ui changes endpoint
|
|
* Format: ~YYYY.M.D..HH.MM.SS..XXXX (only date changes, time/hex stay constant)
|
|
*/
|
|
function formatChangesDate(daysAgo = 5) {
|
|
const now = new Date();
|
|
const targetDate = new Date(now - (daysAgo * 24 * 60 * 60 * 1000));
|
|
const year = targetDate.getFullYear();
|
|
const month = targetDate.getMonth() + 1;
|
|
const day = targetDate.getDate();
|
|
// Keep time and hex constant as per Urbit convention
|
|
return `~${year}.${month}.${day}..20.19.51..9b9d`;
|
|
}
|
|
|
|
/**
|
|
* Fetches changes from groups-ui since a specific date
|
|
* Returns delta data that can be used to efficiently discover new channels
|
|
*/
|
|
async function fetchGroupChanges(api, runtime, daysAgo = 5) {
|
|
try {
|
|
const changeDate = formatChangesDate(daysAgo);
|
|
runtime.log?.(`[tlon] Fetching group changes since ${daysAgo} days ago (${changeDate})...`);
|
|
|
|
const changes = await api.scry(`/groups-ui/v5/changes/${changeDate}.json`);
|
|
|
|
if (changes) {
|
|
runtime.log?.(`[tlon] Successfully fetched changes data`);
|
|
return changes;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
runtime.log?.(`[tlon] Failed to fetch changes (falling back to full init): ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches all channels the ship has access to
|
|
* Returns an array of channel nest identifiers (e.g., "chat/~host-ship/channel-name")
|
|
* Tries changes endpoint first for efficiency, falls back to full init
|
|
*/
|
|
async function fetchAllChannels(api, runtime) {
|
|
try {
|
|
runtime.log?.(`[tlon] Attempting auto-discovery of group channels...`);
|
|
|
|
// Try delta-based changes first (more efficient)
|
|
const changes = await fetchGroupChanges(api, runtime, 5);
|
|
|
|
let initData;
|
|
if (changes) {
|
|
// We got changes, but still need to extract channel info
|
|
// For now, fall back to full init since changes format varies
|
|
runtime.log?.(`[tlon] Changes data received, using full init for channel extraction`);
|
|
initData = await api.scry("/groups-ui/v6/init.json");
|
|
} else {
|
|
// No changes data, use full init
|
|
initData = await api.scry("/groups-ui/v6/init.json");
|
|
}
|
|
|
|
const channels = [];
|
|
|
|
// Extract chat channels from the groups data structure
|
|
if (initData && initData.groups) {
|
|
for (const [groupKey, groupData] of Object.entries(initData.groups)) {
|
|
if (groupData.channels) {
|
|
for (const channelNest of Object.keys(groupData.channels)) {
|
|
// Only include chat channels (not diary, heap, etc.)
|
|
if (channelNest.startsWith("chat/")) {
|
|
channels.push(channelNest);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (channels.length > 0) {
|
|
runtime.log?.(`[tlon] Auto-discovered ${channels.length} chat channel(s)`);
|
|
runtime.log?.(`[tlon] Channels: ${channels.slice(0, 5).join(", ")}${channels.length > 5 ? "..." : ""}`);
|
|
} else {
|
|
runtime.log?.(`[tlon] No chat channels found via auto-discovery`);
|
|
runtime.log?.(`[tlon] Add channels manually to config: channels.tlon.groupChannels`);
|
|
}
|
|
|
|
return channels;
|
|
} catch (error) {
|
|
runtime.log?.(`[tlon] Auto-discovery failed: ${error.message}`);
|
|
runtime.log?.(`[tlon] To monitor group channels, add them to config: channels.tlon.groupChannels`);
|
|
runtime.log?.(`[tlon] Example: ["chat/~host-ship/channel-name"]`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Monitors Tlon/Urbit for incoming DMs and group messages
|
|
*/
|
|
export async function monitorTlonProvider(opts = {}) {
|
|
const runtime = opts.runtime ?? {
|
|
log: console.log,
|
|
error: console.error,
|
|
};
|
|
|
|
const account = opts.account;
|
|
if (!account) {
|
|
throw new Error("Tlon account configuration required");
|
|
}
|
|
|
|
runtime.log?.(`[tlon] Account config: ${JSON.stringify({
|
|
showModelSignature: account.showModelSignature,
|
|
ship: account.ship,
|
|
hasCode: !!account.code,
|
|
hasUrl: !!account.url
|
|
})}`);
|
|
|
|
const botShipName = account.ship.startsWith("~")
|
|
? account.ship
|
|
: `~${account.ship}`;
|
|
|
|
runtime.log?.(`[tlon] Starting monitor for ${botShipName}`);
|
|
|
|
// Authenticate with Urbit
|
|
let api;
|
|
let cookie;
|
|
try {
|
|
runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`);
|
|
runtime.log?.(`[tlon] Ship: ${account.ship.replace(/^~/, "")}`);
|
|
|
|
cookie = await authenticate(account.url, account.code);
|
|
runtime.log?.(`[tlon] Successfully authenticated to ${account.url}`);
|
|
|
|
// Create custom SSE client
|
|
api = new UrbitSSEClient(account.url, cookie);
|
|
} catch (error) {
|
|
runtime.error?.(`[tlon] Failed to authenticate: ${error.message}`);
|
|
throw error;
|
|
}
|
|
|
|
// Get list of group channels to monitor
|
|
let groupChannels = [];
|
|
|
|
// Try auto-discovery first (unless explicitly disabled)
|
|
if (account.autoDiscoverChannels !== false) {
|
|
try {
|
|
const discoveredChannels = await fetchAllChannels(api, runtime);
|
|
if (discoveredChannels.length > 0) {
|
|
groupChannels = discoveredChannels;
|
|
runtime.log?.(`[tlon] Auto-discovered ${groupChannels.length} channel(s)`);
|
|
}
|
|
} catch (error) {
|
|
runtime.error?.(`[tlon] Auto-discovery failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Fall back to manual config if auto-discovery didn't find anything
|
|
if (groupChannels.length === 0 && account.groupChannels && account.groupChannels.length > 0) {
|
|
groupChannels = account.groupChannels;
|
|
runtime.log?.(`[tlon] Using manual groupChannels config: ${groupChannels.join(", ")}`);
|
|
}
|
|
|
|
if (groupChannels.length > 0) {
|
|
runtime.log?.(
|
|
`[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}`
|
|
);
|
|
} else {
|
|
runtime.log?.(`[tlon] No group channels to monitor (DMs only)`);
|
|
}
|
|
|
|
// Keep track of processed message IDs to avoid duplicates
|
|
const processedMessages = new Set();
|
|
|
|
/**
|
|
* Handler for incoming DM messages
|
|
*/
|
|
const handleIncomingDM = async (update) => {
|
|
try {
|
|
runtime.log?.(`[tlon] DM handler called with update: ${JSON.stringify(update).substring(0, 200)}`);
|
|
|
|
// Handle new DM event format: response.add.memo or response.reply.delta.add.memo (for threads)
|
|
let memo = update?.response?.add?.memo;
|
|
let parentId = null;
|
|
let replyId = null;
|
|
|
|
// Check if this is a thread reply
|
|
if (!memo && update?.response?.reply) {
|
|
memo = update?.response?.reply?.delta?.add?.memo;
|
|
parentId = update.id; // The parent post ID
|
|
replyId = update?.response?.reply?.id; // The reply message ID
|
|
runtime.log?.(`[tlon] Thread reply detected, parent: ${parentId}, reply: ${replyId}`);
|
|
}
|
|
|
|
if (!memo) {
|
|
runtime.log?.(`[tlon] DM update has no memo in response.add or response.reply`);
|
|
return;
|
|
}
|
|
|
|
const messageId = replyId || update.id;
|
|
if (processedMessages.has(messageId)) return;
|
|
processedMessages.add(messageId);
|
|
|
|
const senderShip = memo.author?.startsWith("~")
|
|
? memo.author
|
|
: `~${memo.author}`;
|
|
|
|
const messageText = extractMessageText(memo.content);
|
|
if (!messageText) return;
|
|
|
|
// Determine which user's DM cache to use (the other party, not the bot)
|
|
const otherParty = senderShip === botShipName ? update.whom : senderShip;
|
|
const dmCacheKey = `dm/${otherParty}`;
|
|
|
|
// Cache all DM messages (including bot's own) for history retrieval
|
|
if (!messageCache.has(dmCacheKey)) {
|
|
messageCache.set(dmCacheKey, []);
|
|
}
|
|
const cache = messageCache.get(dmCacheKey);
|
|
cache.unshift({
|
|
id: messageId,
|
|
author: senderShip,
|
|
content: messageText,
|
|
timestamp: memo.sent || Date.now(),
|
|
});
|
|
// Keep only last 50 messages
|
|
if (cache.length > 50) {
|
|
cache.length = 50;
|
|
}
|
|
|
|
// Don't respond to our own messages
|
|
if (senderShip === botShipName) return;
|
|
|
|
// Check DM access control
|
|
if (!isDmAllowed(senderShip, account)) {
|
|
runtime.log?.(
|
|
`[tlon] Blocked DM from ${senderShip}: not in allowed list`
|
|
);
|
|
return;
|
|
}
|
|
|
|
runtime.log?.(
|
|
`[tlon] Received DM from ${senderShip}: "${messageText.slice(0, 50)}..."${parentId ? ' (thread reply)' : ''}`
|
|
);
|
|
|
|
// All DMs are processed (no mention check needed)
|
|
|
|
await processMessage({
|
|
messageId,
|
|
senderShip,
|
|
messageText,
|
|
isGroup: false,
|
|
timestamp: memo.sent || Date.now(),
|
|
parentId, // Pass parentId for thread replies
|
|
});
|
|
} catch (error) {
|
|
runtime.error?.(`[tlon] Error handling DM: ${error.message}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handler for incoming group channel messages
|
|
*/
|
|
const handleIncomingGroupMessage = (channelNest) => async (update) => {
|
|
try {
|
|
runtime.log?.(`[tlon] Group handler called for ${channelNest} with update: ${JSON.stringify(update).substring(0, 200)}`);
|
|
const parsed = parseChannelNest(channelNest);
|
|
if (!parsed) return;
|
|
|
|
const { hostShip, channelName } = parsed;
|
|
|
|
// Handle both top-level posts and thread replies
|
|
// Top-level: response.post.r-post.set.essay
|
|
// Thread reply: response.post.r-post.reply.r-reply.set.memo
|
|
const essay = update?.response?.post?.["r-post"]?.set?.essay;
|
|
const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
|
|
|
|
if (!essay && !memo) {
|
|
runtime.log?.(`[tlon] Group update has neither essay nor memo`);
|
|
return;
|
|
}
|
|
|
|
// Use memo for thread replies, essay for top-level posts
|
|
const content = memo || essay;
|
|
const isThreadReply = !!memo;
|
|
|
|
// For thread replies, use the reply ID, not the parent post ID
|
|
const messageId = isThreadReply
|
|
? update.response.post["r-post"]?.reply?.id
|
|
: update.response.post.id;
|
|
|
|
if (processedMessages.has(messageId)) {
|
|
runtime.log?.(`[tlon] Skipping duplicate message ${messageId}`);
|
|
return;
|
|
}
|
|
processedMessages.add(messageId);
|
|
|
|
const senderShip = content.author?.startsWith("~")
|
|
? content.author
|
|
: `~${content.author}`;
|
|
|
|
// Don't respond to our own messages
|
|
if (senderShip === botShipName) return;
|
|
|
|
const messageText = extractMessageText(content.content);
|
|
if (!messageText) return;
|
|
|
|
// Cache this message for history/summarization
|
|
cacheMessage(channelNest, {
|
|
author: senderShip,
|
|
content: messageText,
|
|
timestamp: content.sent || Date.now(),
|
|
id: messageId,
|
|
});
|
|
|
|
// Check if bot is mentioned
|
|
const mentioned = isBotMentioned(messageText, botShipName);
|
|
|
|
runtime.log?.(
|
|
`[tlon] Received group message in ${channelNest} from ${senderShip}: "${messageText.slice(0, 50)}..." (mentioned: ${mentioned})`
|
|
);
|
|
|
|
// Only process if bot is mentioned
|
|
if (!mentioned) return;
|
|
|
|
// Check channel authorization
|
|
const tlonConfig = opts.cfg?.channels?.tlon;
|
|
const authorization = tlonConfig?.authorization || {};
|
|
const channelRules = authorization.channelRules || {};
|
|
const defaultAuthorizedShips = tlonConfig?.defaultAuthorizedShips || ["~malmur-halmex"];
|
|
|
|
// Get channel rule or use default (restricted)
|
|
const channelRule = channelRules[channelNest];
|
|
const mode = channelRule?.mode || "restricted"; // Default to restricted
|
|
const allowedShips = channelRule?.allowedShips || defaultAuthorizedShips;
|
|
|
|
// Normalize sender ship (ensure it has ~)
|
|
const normalizedSender = senderShip.startsWith("~") ? senderShip : `~${senderShip}`;
|
|
|
|
// Check authorization for restricted channels
|
|
if (mode === "restricted") {
|
|
const isAuthorized = allowedShips.some(ship => {
|
|
const normalizedAllowed = ship.startsWith("~") ? ship : `~${ship}`;
|
|
return normalizedAllowed === normalizedSender;
|
|
});
|
|
|
|
if (!isAuthorized) {
|
|
runtime.log?.(
|
|
`[tlon] ⛔ Access denied: ${normalizedSender} in ${channelNest} (restricted, allowed: ${allowedShips.join(", ")})`
|
|
);
|
|
return;
|
|
}
|
|
|
|
runtime.log?.(
|
|
`[tlon] ✅ Access granted: ${normalizedSender} in ${channelNest} (authorized user)`
|
|
);
|
|
} else {
|
|
runtime.log?.(
|
|
`[tlon] ✅ Access granted: ${normalizedSender} in ${channelNest} (open channel)`
|
|
);
|
|
}
|
|
|
|
// Extract seal data for thread support
|
|
// For thread replies, seal is in a different location
|
|
const seal = isThreadReply
|
|
? update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
|
|
: update?.response?.post?.["r-post"]?.set?.seal;
|
|
|
|
// For thread replies, all messages in the thread share the same parent-id
|
|
// We reply to the parent-id to keep our message in the same thread
|
|
const parentId = seal?.["parent-id"] || seal?.parent || null;
|
|
const postType = update?.response?.post?.["r-post"]?.set?.type;
|
|
|
|
runtime.log?.(
|
|
`[tlon] Message type: ${isThreadReply ? "thread reply" : "top-level post"}, parentId: ${parentId}, messageId: ${seal?.id}`
|
|
);
|
|
|
|
await processMessage({
|
|
messageId,
|
|
senderShip,
|
|
messageText,
|
|
isGroup: true,
|
|
groupChannel: channelNest,
|
|
groupName: `${hostShip}/${channelName}`,
|
|
timestamp: content.sent || Date.now(),
|
|
parentId, // Reply to parent-id to stay in the thread
|
|
postType,
|
|
seal,
|
|
});
|
|
} catch (error) {
|
|
runtime.error?.(
|
|
`[tlon] Error handling group message in ${channelNest}: ${error.message}`
|
|
);
|
|
}
|
|
};
|
|
|
|
// Load core channel deps
|
|
const deps = await loadCoreChannelDeps();
|
|
|
|
/**
|
|
* Process a message and generate AI response
|
|
*/
|
|
const processMessage = async (params) => {
|
|
let {
|
|
messageId,
|
|
senderShip,
|
|
messageText,
|
|
isGroup,
|
|
groupChannel,
|
|
groupName,
|
|
timestamp,
|
|
parentId, // Parent post ID to reply to (for threading)
|
|
postType,
|
|
seal,
|
|
} = params;
|
|
|
|
runtime.log?.(`[tlon] processMessage called for ${senderShip}, isGroup: ${isGroup}, message: "${messageText.substring(0, 50)}"`);
|
|
|
|
// Check if this is a summarization request
|
|
if (isGroup && isSummarizationRequest(messageText)) {
|
|
runtime.log?.(`[tlon] Detected summarization request in ${groupChannel}`);
|
|
try {
|
|
const history = await getChannelHistory(api, groupChannel, 50, runtime);
|
|
if (history.length === 0) {
|
|
const noHistoryMsg = "I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
|
|
if (isGroup) {
|
|
const parsed = parseChannelNest(groupChannel);
|
|
if (parsed) {
|
|
await sendGroupMessage(
|
|
api,
|
|
botShipName,
|
|
parsed.hostShip,
|
|
parsed.channelName,
|
|
noHistoryMsg,
|
|
null,
|
|
runtime
|
|
);
|
|
}
|
|
} else {
|
|
await sendDm(api, botShipName, senderShip, noHistoryMsg);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Format history for AI
|
|
const historyText = history
|
|
.map(msg => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`)
|
|
.join("\n");
|
|
|
|
const summaryPrompt = `Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\nProvide a concise summary highlighting:\n1. Main topics discussed\n2. Key decisions or conclusions\n3. Action items if any\n4. Notable participants`;
|
|
|
|
// Override message text with summary prompt
|
|
messageText = summaryPrompt;
|
|
runtime.log?.(`[tlon] Generating summary for ${history.length} messages`);
|
|
} catch (error) {
|
|
runtime.error?.(`[tlon] Error generating summary: ${error.message}`);
|
|
const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error.message}`;
|
|
if (isGroup) {
|
|
const parsed = parseChannelNest(groupChannel);
|
|
if (parsed) {
|
|
await sendGroupMessage(
|
|
api,
|
|
botShipName,
|
|
parsed.hostShip,
|
|
parsed.channelName,
|
|
errorMsg,
|
|
null,
|
|
runtime
|
|
);
|
|
}
|
|
} else {
|
|
await sendDm(api, botShipName, senderShip, errorMsg);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check if this is a notebook command
|
|
const notebookCommand = parseNotebookCommand(messageText);
|
|
if (notebookCommand) {
|
|
runtime.log?.(`[tlon] Detected notebook command: ${notebookCommand.type}`);
|
|
|
|
// Check if notebookChannel is configured
|
|
const notebookChannel = account.notebookChannel;
|
|
if (!notebookChannel) {
|
|
const errorMsg = "Notebook feature is not configured. Please add a 'notebookChannel' to your Tlon account config (e.g., diary/~malmur-halmex/v2u22f1d).";
|
|
if (isGroup) {
|
|
const parsed = parseChannelNest(groupChannel);
|
|
if (parsed) {
|
|
await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, errorMsg, parentId, runtime);
|
|
}
|
|
} else {
|
|
await sendDm(api, botShipName, senderShip, errorMsg);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle save command
|
|
if (notebookCommand.type === "save_to_notebook") {
|
|
try {
|
|
let noteContent = null;
|
|
let noteTitle = notebookCommand.title;
|
|
|
|
// If replying to a message (thread), save the parent message
|
|
if (parentId) {
|
|
runtime.log?.(`[tlon] Fetching parent message ${parentId} to save`);
|
|
|
|
// For DMs, use messageCache directly since DM history scry isn't available
|
|
if (!isGroup) {
|
|
const dmCacheKey = `dm/${senderShip}`;
|
|
const cache = messageCache.get(dmCacheKey) || [];
|
|
const parentMsg = cache.find(msg => msg.id === parentId || msg.id.includes(parentId));
|
|
|
|
if (parentMsg) {
|
|
noteContent = parentMsg.content;
|
|
if (!noteTitle) {
|
|
// Generate title from first line or first 60 chars of content
|
|
const firstLine = noteContent.split('\n')[0];
|
|
noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine;
|
|
}
|
|
} else {
|
|
noteContent = "Could not find parent message in cache";
|
|
noteTitle = noteTitle || "Note";
|
|
}
|
|
} else {
|
|
const history = await getChannelHistory(api, groupChannel, 50, runtime);
|
|
const parentMsg = history.find(msg => msg.id === parentId || msg.id.includes(parentId));
|
|
|
|
if (parentMsg) {
|
|
noteContent = parentMsg.content;
|
|
if (!noteTitle) {
|
|
// Generate title from first line or first 60 chars of content
|
|
const firstLine = noteContent.split('\n')[0];
|
|
noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine;
|
|
}
|
|
} else {
|
|
noteContent = "Could not find parent message";
|
|
noteTitle = noteTitle || "Note";
|
|
}
|
|
}
|
|
} else {
|
|
// No parent - fetch last bot message
|
|
if (!isGroup) {
|
|
const dmCacheKey = `dm/${senderShip}`;
|
|
const cache = messageCache.get(dmCacheKey) || [];
|
|
const lastBotMsg = cache.find(msg => msg.author === botShipName);
|
|
|
|
if (lastBotMsg) {
|
|
noteContent = lastBotMsg.content;
|
|
if (!noteTitle) {
|
|
// Generate title from first line or first 60 chars of content
|
|
const firstLine = noteContent.split('\n')[0];
|
|
noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine;
|
|
}
|
|
} else {
|
|
noteContent = "No recent bot message found in cache";
|
|
noteTitle = noteTitle || "Note";
|
|
}
|
|
} else {
|
|
const history = await getChannelHistory(api, groupChannel, 10, runtime);
|
|
const lastBotMsg = history.find(msg => msg.author === botShipName);
|
|
|
|
if (lastBotMsg) {
|
|
noteContent = lastBotMsg.content;
|
|
if (!noteTitle) {
|
|
// Generate title from first line or first 60 chars of content
|
|
const firstLine = noteContent.split('\n')[0];
|
|
noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine;
|
|
}
|
|
} else {
|
|
noteContent = "No recent bot message found";
|
|
noteTitle = noteTitle || "Note";
|
|
}
|
|
}
|
|
}
|
|
|
|
const { essayId, sentAt } = await sendDiaryPost(
|
|
api,
|
|
account,
|
|
notebookChannel,
|
|
noteTitle,
|
|
noteContent
|
|
);
|
|
|
|
const successMsg = `✓ Saved to notebook as "${noteTitle}"`;
|
|
runtime.log?.(`[tlon] Saved note ${essayId} to ${notebookChannel}`);
|
|
|
|
if (isGroup) {
|
|
const parsed = parseChannelNest(groupChannel);
|
|
if (parsed) {
|
|
await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, successMsg, parentId, runtime);
|
|
}
|
|
} else {
|
|
await sendDm(api, botShipName, senderShip, successMsg);
|
|
}
|
|
} catch (error) {
|
|
runtime.error?.(`[tlon] Error saving to notebook: ${error.message}`);
|
|
const errorMsg = `Failed to save to notebook: ${error.message}`;
|
|
if (isGroup) {
|
|
const parsed = parseChannelNest(groupChannel);
|
|
if (parsed) {
|
|
await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, errorMsg, parentId, runtime);
|
|
}
|
|
} else {
|
|
await sendDm(api, botShipName, senderShip, errorMsg);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle list command (placeholder for now)
|
|
if (notebookCommand.type === "list_notebook") {
|
|
const placeholderMsg = "List notebook handler not yet implemented.";
|
|
if (isGroup) {
|
|
const parsed = parseChannelNest(groupChannel);
|
|
if (parsed) {
|
|
await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, placeholderMsg, parentId, runtime);
|
|
}
|
|
} else {
|
|
await sendDm(api, botShipName, senderShip, placeholderMsg);
|
|
}
|
|
return;
|
|
}
|
|
|
|
return; // Don't send to AI for notebook commands
|
|
}
|
|
|
|
try {
|
|
// Resolve agent route
|
|
const route = deps.resolveAgentRoute({
|
|
cfg: opts.cfg,
|
|
channel: "tlon",
|
|
accountId: opts.accountId,
|
|
peer: {
|
|
kind: isGroup ? "group" : "dm",
|
|
id: isGroup ? groupChannel : senderShip,
|
|
},
|
|
});
|
|
|
|
// Format message for AI
|
|
const fromLabel = isGroup
|
|
? `${senderShip} in ${groupName}`
|
|
: senderShip;
|
|
|
|
// Add Tlon identity context to help AI recognize when it's being addressed
|
|
// The AI knows itself as "bearclawd" but in Tlon it's addressed as the ship name
|
|
const identityNote = `[Note: In Tlon/Urbit, you are known as ${botShipName}. When users mention ${botShipName}, they are addressing you directly.]\n\n`;
|
|
const messageWithIdentity = identityNote + messageText;
|
|
|
|
const body = deps.formatAgentEnvelope({
|
|
channel: "Tlon",
|
|
from: fromLabel,
|
|
timestamp,
|
|
body: messageWithIdentity,
|
|
});
|
|
|
|
// Create inbound context
|
|
// For thread replies, append parent ID to session key to create separate conversation context
|
|
const sessionKeySuffix = parentId ? `:thread:${parentId}` : '';
|
|
const finalSessionKey = `${route.sessionKey}${sessionKeySuffix}`;
|
|
|
|
runtime.log?.(
|
|
`[tlon] 🔑 Session key construction: base="${route.sessionKey}", suffix="${sessionKeySuffix}", final="${finalSessionKey}"`
|
|
);
|
|
|
|
const ctxPayload = deps.finalizeInboundContext({
|
|
Body: body,
|
|
RawBody: messageText,
|
|
CommandBody: messageText,
|
|
From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
|
|
To: `tlon:${botShipName}`,
|
|
SessionKey: finalSessionKey,
|
|
AccountId: route.accountId,
|
|
ChatType: isGroup ? "group" : "direct",
|
|
ConversationLabel: fromLabel,
|
|
SenderName: senderShip,
|
|
SenderId: senderShip,
|
|
Provider: "tlon",
|
|
Surface: "tlon",
|
|
MessageSid: messageId,
|
|
OriginatingChannel: "tlon",
|
|
OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
|
|
});
|
|
|
|
runtime.log?.(
|
|
`[tlon] 📋 Context payload keys: ${Object.keys(ctxPayload).join(', ')}`
|
|
);
|
|
runtime.log?.(
|
|
`[tlon] 📋 Message body: "${body.substring(0, 100)}${body.length > 100 ? '...' : ''}"`
|
|
);
|
|
|
|
// Log transcript details
|
|
if (ctxPayload.Transcript && ctxPayload.Transcript.length > 0) {
|
|
runtime.log?.(
|
|
`[tlon] 📜 Transcript has ${ctxPayload.Transcript.length} message(s)`
|
|
);
|
|
// Log last few messages for debugging
|
|
const recentMessages = ctxPayload.Transcript.slice(-3);
|
|
recentMessages.forEach((msg, idx) => {
|
|
runtime.log?.(
|
|
`[tlon] 📜 Transcript[-${3-idx}]: role=${msg.role}, content length=${JSON.stringify(msg.content).length}`
|
|
);
|
|
});
|
|
} else {
|
|
runtime.log?.(
|
|
`[tlon] 📜 Transcript is empty or missing`
|
|
);
|
|
}
|
|
|
|
// Log key fields that affect AI behavior
|
|
runtime.log?.(
|
|
`[tlon] 📝 BodyForAgent: "${ctxPayload.BodyForAgent?.substring(0, 100)}${(ctxPayload.BodyForAgent?.length || 0) > 100 ? '...' : ''}"`
|
|
);
|
|
runtime.log?.(
|
|
`[tlon] 📝 ThreadStarterBody: "${ctxPayload.ThreadStarterBody?.substring(0, 100) || 'null'}${(ctxPayload.ThreadStarterBody?.length || 0) > 100 ? '...' : ''}"`
|
|
);
|
|
runtime.log?.(
|
|
`[tlon] 📝 CommandAuthorized: ${ctxPayload.CommandAuthorized}`
|
|
);
|
|
|
|
// Dispatch to AI and get response
|
|
const dispatchStartTime = Date.now();
|
|
runtime.log?.(
|
|
`[tlon] Dispatching to AI for ${senderShip} (${isGroup ? `group: ${groupName}` : 'DM'})`
|
|
);
|
|
runtime.log?.(
|
|
`[tlon] 🚀 Dispatch details: sessionKey="${finalSessionKey}", isThreadReply=${!!parentId}, messageText="${messageText.substring(0, 50)}..."`
|
|
);
|
|
|
|
const dispatchResult = await deps.dispatchReplyWithBufferedBlockDispatcher({
|
|
ctx: ctxPayload,
|
|
cfg: opts.cfg,
|
|
dispatcherOptions: {
|
|
deliver: async (payload) => {
|
|
runtime.log?.(`[tlon] 🎯 Deliver callback invoked! isThreadReply=${!!parentId}, parentId=${parentId}`);
|
|
const dispatchDuration = Date.now() - dispatchStartTime;
|
|
runtime.log?.(`[tlon] 📦 Payload keys: ${Object.keys(payload).join(', ')}, text length: ${payload.text?.length || 0}`);
|
|
let replyText = payload.text;
|
|
|
|
if (!replyText) {
|
|
runtime.log?.(`[tlon] No reply text in AI response (took ${dispatchDuration}ms)`);
|
|
return;
|
|
}
|
|
|
|
// Add model signature if enabled
|
|
const tlonConfig = opts.cfg?.channels?.tlon;
|
|
const showSignature = tlonConfig?.showModelSignature ?? false;
|
|
runtime.log?.(`[tlon] showModelSignature config: ${showSignature} (from cfg.channels.tlon)`);
|
|
runtime.log?.(`[tlon] Full payload keys: ${Object.keys(payload).join(', ')}`);
|
|
runtime.log?.(`[tlon] Full route keys: ${Object.keys(route).join(', ')}`);
|
|
runtime.log?.(`[tlon] opts.cfg.agents: ${JSON.stringify(opts.cfg?.agents?.defaults?.model)}`);
|
|
if (showSignature) {
|
|
const modelInfo = payload.metadata?.model || payload.model || route.model || opts.cfg?.agents?.defaults?.model?.primary;
|
|
runtime.log?.(`[tlon] Model info: ${JSON.stringify({
|
|
payloadMetadataModel: payload.metadata?.model,
|
|
payloadModel: payload.model,
|
|
routeModel: route.model,
|
|
cfgModel: opts.cfg?.agents?.defaults?.model?.primary,
|
|
resolved: modelInfo
|
|
})}`);
|
|
if (modelInfo) {
|
|
const modelName = formatModelName(modelInfo);
|
|
runtime.log?.(`[tlon] Adding signature: ${modelName}`);
|
|
replyText = `${replyText}\n\n_[Generated by ${modelName}]_`;
|
|
} else {
|
|
runtime.log?.(`[tlon] No model info found, using fallback`);
|
|
replyText = `${replyText}\n\n_[Generated by AI]_`;
|
|
}
|
|
}
|
|
|
|
runtime.log?.(
|
|
`[tlon] AI response received (took ${dispatchDuration}ms), sending to Tlon...`
|
|
);
|
|
|
|
// Debug delivery path
|
|
runtime.log?.(`[tlon] 🔍 Delivery debug: isGroup=${isGroup}, groupChannel=${groupChannel}, senderShip=${senderShip}, parentId=${parentId}`);
|
|
|
|
// Send reply back to Tlon
|
|
if (isGroup) {
|
|
const parsed = parseChannelNest(groupChannel);
|
|
runtime.log?.(`[tlon] 🔍 Parsed channel nest: ${JSON.stringify(parsed)}`);
|
|
if (parsed) {
|
|
// Reply in thread if this message is part of a thread
|
|
if (parentId) {
|
|
runtime.log?.(`[tlon] Replying in thread (parent: ${parentId})`);
|
|
}
|
|
await sendGroupMessage(
|
|
api,
|
|
botShipName,
|
|
parsed.hostShip,
|
|
parsed.channelName,
|
|
replyText,
|
|
parentId, // Pass parentId to reply in the thread
|
|
runtime
|
|
);
|
|
const threadInfo = parentId ? ` (in thread)` : '';
|
|
runtime.log?.(`[tlon] Delivered AI reply to group ${groupName}${threadInfo}`);
|
|
} else {
|
|
runtime.log?.(`[tlon] ⚠️ Failed to parse channel nest: ${groupChannel}`);
|
|
}
|
|
} else {
|
|
await sendDm(api, botShipName, senderShip, replyText);
|
|
runtime.log?.(`[tlon] Delivered AI reply to ${senderShip}`);
|
|
}
|
|
},
|
|
onError: (err, info) => {
|
|
const dispatchDuration = Date.now() - dispatchStartTime;
|
|
runtime.error?.(
|
|
`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`
|
|
);
|
|
runtime.error?.(`[tlon] Error type: ${err?.constructor?.name || 'Unknown'}`);
|
|
runtime.error?.(`[tlon] Error details: ${JSON.stringify(info, null, 2)}`);
|
|
if (err?.stack) {
|
|
runtime.error?.(`[tlon] Stack trace: ${err.stack}`);
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
const totalDuration = Date.now() - dispatchStartTime;
|
|
runtime.log?.(
|
|
`[tlon] AI dispatch completed for ${senderShip} (total: ${totalDuration}ms), result keys: ${dispatchResult ? Object.keys(dispatchResult).join(', ') : 'null'}`
|
|
);
|
|
runtime.log?.(`[tlon] Dispatch result: ${JSON.stringify(dispatchResult)}`);
|
|
} catch (error) {
|
|
runtime.error?.(`[tlon] Error processing message: ${error.message}`);
|
|
runtime.error?.(`[tlon] Stack trace: ${error.stack}`);
|
|
}
|
|
};
|
|
|
|
// Track currently subscribed channels for dynamic updates
|
|
const subscribedChannels = new Set(); // Start empty, add after successful subscription
|
|
const subscribedDMs = new Set();
|
|
|
|
/**
|
|
* Subscribe to a group channel
|
|
*/
|
|
async function subscribeToChannel(channelNest) {
|
|
if (subscribedChannels.has(channelNest)) {
|
|
return; // Already subscribed
|
|
}
|
|
|
|
const parsed = parseChannelNest(channelNest);
|
|
if (!parsed) {
|
|
runtime.error?.(
|
|
`[tlon] Invalid channel format: ${channelNest} (expected: chat/~host-ship/channel-name)`
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.subscribe({
|
|
app: "channels",
|
|
path: `/${channelNest}`,
|
|
event: handleIncomingGroupMessage(channelNest),
|
|
err: (error) => {
|
|
runtime.error?.(
|
|
`[tlon] Group subscription error for ${channelNest}: ${error}`
|
|
);
|
|
},
|
|
quit: () => {
|
|
runtime.log?.(`[tlon] Group subscription ended for ${channelNest}`);
|
|
subscribedChannels.delete(channelNest);
|
|
},
|
|
});
|
|
subscribedChannels.add(channelNest);
|
|
runtime.log?.(`[tlon] Subscribed to group channel: ${channelNest}`);
|
|
} catch (error) {
|
|
runtime.error?.(`[tlon] Failed to subscribe to ${channelNest}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe to a DM conversation
|
|
*/
|
|
async function subscribeToDM(dmShip) {
|
|
if (subscribedDMs.has(dmShip)) {
|
|
return; // Already subscribed
|
|
}
|
|
|
|
try {
|
|
await api.subscribe({
|
|
app: "chat",
|
|
path: `/dm/${dmShip}`,
|
|
event: handleIncomingDM,
|
|
err: (error) => {
|
|
runtime.error?.(`[tlon] DM subscription error for ${dmShip}: ${error}`);
|
|
},
|
|
quit: () => {
|
|
runtime.log?.(`[tlon] DM subscription ended for ${dmShip}`);
|
|
subscribedDMs.delete(dmShip);
|
|
},
|
|
});
|
|
subscribedDMs.add(dmShip);
|
|
runtime.log?.(`[tlon] Subscribed to DM with ${dmShip}`);
|
|
} catch (error) {
|
|
runtime.error?.(`[tlon] Failed to subscribe to DM with ${dmShip}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Discover and subscribe to new channels
|
|
*/
|
|
async function refreshChannelSubscriptions() {
|
|
try {
|
|
// Check for new DMs
|
|
const dmShips = await api.scry("/chat/dm.json");
|
|
for (const dmShip of dmShips) {
|
|
await subscribeToDM(dmShip);
|
|
}
|
|
|
|
// Check for new group channels (if auto-discovery is enabled)
|
|
if (account.autoDiscoverChannels !== false) {
|
|
const discoveredChannels = await fetchAllChannels(api, runtime);
|
|
|
|
// Find truly new channels (not already subscribed)
|
|
const newChannels = discoveredChannels.filter(c => !subscribedChannels.has(c));
|
|
|
|
if (newChannels.length > 0) {
|
|
runtime.log?.(`[tlon] 🆕 Discovered ${newChannels.length} new channel(s):`);
|
|
newChannels.forEach(c => runtime.log?.(`[tlon] - ${c}`));
|
|
}
|
|
|
|
// Subscribe to all discovered channels (including new ones)
|
|
for (const channelNest of discoveredChannels) {
|
|
await subscribeToChannel(channelNest);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
runtime.error?.(`[tlon] Channel refresh failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Subscribe to incoming messages
|
|
try {
|
|
runtime.log?.(`[tlon] Subscribing to updates...`);
|
|
|
|
// Get list of DM ships and subscribe to each one
|
|
let dmShips = [];
|
|
try {
|
|
dmShips = await api.scry("/chat/dm.json");
|
|
runtime.log?.(`[tlon] Found ${dmShips.length} DM conversation(s)`);
|
|
} catch (error) {
|
|
runtime.error?.(`[tlon] Failed to fetch DM list: ${error.message}`);
|
|
}
|
|
|
|
// Subscribe to each DM individually
|
|
for (const dmShip of dmShips) {
|
|
await subscribeToDM(dmShip);
|
|
}
|
|
|
|
// Subscribe to each group channel
|
|
for (const channelNest of groupChannels) {
|
|
await subscribeToChannel(channelNest);
|
|
}
|
|
|
|
runtime.log?.(`[tlon] All subscriptions registered, connecting to SSE stream...`);
|
|
|
|
// Connect to Urbit and start the SSE stream
|
|
await api.connect();
|
|
|
|
runtime.log?.(`[tlon] Connected! All subscriptions active`);
|
|
|
|
// Start dynamic channel discovery (poll every 2 minutes)
|
|
const POLL_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
|
|
const pollInterval = setInterval(() => {
|
|
if (!opts.abortSignal?.aborted) {
|
|
runtime.log?.(`[tlon] Checking for new channels...`);
|
|
refreshChannelSubscriptions().catch((error) => {
|
|
runtime.error?.(`[tlon] Channel refresh error: ${error.message}`);
|
|
});
|
|
}
|
|
}, POLL_INTERVAL_MS);
|
|
|
|
runtime.log?.(`[tlon] Dynamic channel discovery enabled (checking every 2 minutes)`);
|
|
|
|
// Keep the monitor running until aborted
|
|
if (opts.abortSignal) {
|
|
await new Promise((resolve) => {
|
|
opts.abortSignal.addEventListener("abort", () => {
|
|
clearInterval(pollInterval);
|
|
resolve();
|
|
}, {
|
|
once: true,
|
|
});
|
|
});
|
|
} else {
|
|
// If no abort signal, wait indefinitely
|
|
await new Promise(() => {});
|
|
}
|
|
} catch (error) {
|
|
if (opts.abortSignal?.aborted) {
|
|
runtime.log?.(`[tlon] Monitor stopped`);
|
|
return;
|
|
}
|
|
throw error;
|
|
} finally {
|
|
// Cleanup
|
|
try {
|
|
await api.close();
|
|
} catch (e) {
|
|
runtime.error?.(`[tlon] Cleanup error: ${e.message}`);
|
|
}
|
|
}
|
|
}
|