* feat(tlon): sync with openclaw-tlon master - Add tlon CLI tool registration with binary lookup - Add approval, media, settings, foreigns, story, upload modules - Add http-api wrapper for Urbit connection patching - Update types for defaultAuthorizedShips support - Fix type compatibility with core plugin SDK - Stub uploadFile (API not yet available in @tloncorp/api-beta) - Remove incompatible test files (security, sse-client, upload) * chore(tlon): remove dead code Remove unused Urbit channel client files: - channel-client.ts - channel-ops.ts - context.ts These were not imported anywhere in the extension. * feat(tlon): add image upload support via @tloncorp/api - Import configureClient and uploadFile from @tloncorp/api - Implement uploadImageFromUrl using uploadFile - Configure API client before media uploads - Update dependency to github:tloncorp/api-beta#main * fix(tlon): restore SSRF protection with event ack tracking - Restore context.ts and channel-ops.ts for SSRF support - Restore sse-client.ts with urbitFetch for SSRF-protected requests - Add event ack tracking from openclaw-tlon (acks every 20 events) - Pass ssrfPolicy through authenticate() and UrbitSSEClient - Fixes security regression from sync with openclaw-tlon * fix(tlon): restore buildTlonAccountFields for allowPrivateNetwork The inlined payload building was missing allowPrivateNetwork field, which would prevent the setting from being persisted to config. * fix(tlon): restore SSRF protection in probeAccount - Restore channel-client.ts for UrbitChannelClient - Use UrbitChannelClient with ssrfPolicy in probeAccount - Ensures account probe respects allowPrivateNetwork setting * feat(tlon): add ownerShip to setup flow ownerShip should always be set as it controls who receives approval requests and can approve/deny actions. * chore(tlon): remove unused http-api.ts After restoring SSRF protection, probeAccount uses UrbitChannelClient instead of @urbit/http-api. The http-api.ts wrapper is no longer needed. * refactor(tlon): simplify probeAccount to direct /~/name request No channel needed - just authenticate and GET /~/name. Removes UrbitChannelClient, keeping only UrbitSSEClient for monitor. * chore(tlon): add logging for event acks * chore(tlon): lower ack threshold to 5 for testing * fix(tlon): address security review issues - Fix SSRF in upload.ts: use urbitFetch with SSRF protection - Fix SSRF in media.ts: use urbitFetch with SSRF protection - Add command whitelist to tlon tool to prevent command injection - Add getDefaultSsrFPolicy() helper for uploads/downloads * fix(tlon): restore auth retry and add reauth on SSE reconnect - Add authenticateWithRetry() helper with exponential backoff (restores lost logic from #39) - Add onReconnect callback to re-authenticate when SSE stream reconnects - Add UrbitSSEClient.updateCookie() method for proper cookie normalization on reauth * fix(tlon): add infinite reconnect with reset after max attempts Instead of giving up after maxReconnectAttempts, wait 10 seconds then reset the counter and keep trying. This ensures the monitor never permanently disconnects due to temporary network issues. * test(tlon): restore security, sse-client, and upload tests - security.test.ts: DM allowlist, group invite, bot mention detection, ship normalization - sse-client.test.ts: subscription handling, cookie updates, reconnection params - upload.test.ts: image upload with SSRF protection, error handling * fix(tlon): restore DM partner ship extraction for proper routing - Add extractDmPartnerShip() to extract partner from 'whom' field - Use partner ship for routing (more reliable than essay.author) - Explicitly ignore bot's own outbound DM events - Log mismatch between author and partner for debugging * chore(tlon): restore ack threshold to 20 * chore(tlon): sync slash commands support from upstream - Add stripBotMention for proper CommandBody parsing - Add command authorization logic for owner-only slash commands - Add CommandAuthorized and CommandSource to context payload * fix(tlon): resolve TypeScript errors in tests and monitor - Store validated account url/code before closure to fix type narrowing - Fix test type annotations for mode rules - Add proper Response type cast in sse-client mock - Use optional chaining for init properties * docs(tlon): update docs for new config options and capabilities - Document ownerShip for approval system - Document autoAcceptDmInvites and autoAcceptGroupInvites - Update status to reflect rich text and image support - Add bundled skill section - Update notes with formatting and image details - Fix pnpm-lock.yaml conflict * docs(tlon): fix dmAllowlist description and improve allowPrivateNetwork docs - Correct dmAllowlist: empty means no DMs allowed (not allow all) - Promote allowPrivateNetwork to its own section with examples - Add warning about SSRF protection implications * docs(tlon): clarify ownerShip is auto-authorized everywhere - Add ownerShip to minimal config example (recommended) - Document that owner is automatically allowed for DMs and channels - No need to add owner to dmAllowlist or defaultAuthorizedShips * docs(tlon): add capabilities table, troubleshooting, and config reference Align with Matrix docs format: - Capabilities table for quick feature reference - Troubleshooting section with common failures - Configuration reference with all options * docs(tlon): fix reactions status and expand bundled skill section - Reactions ARE supported via bundled skill (not missing) - Add link to skill GitHub repo - List skill capabilities: contacts, channels, groups, DMs, reactions, settings * fix(tlon): use crypto.randomUUID instead of Math.random for channel ID Fixes security test failure - Math.random is flagged as weak randomness. * docs: fix markdown lint - add blank line before </Step> * fix: address PR review issues for tlon plugin - upload.ts: Use fetchWithSsrFGuard directly instead of urbitFetch to preserve full URL path when fetching external images; add release() call - media.ts: Same fix - use fetchWithSsrFGuard for external media downloads; add release() call to clean up resources - channel.ts: Use urbitFetch for poke API to maintain consistent SSRF protection (DNS pinning + redirect handling) - upload.test.ts: Update mocks to use fetchWithSsrFGuard instead of urbitFetch Addresses blocking issues from jalehman's review: 1. Fixed incorrect URL being fetched (validateUrbitBaseUrl was stripping path) 2. Fixed missing release() calls that could leak resources 3. Restored guarded fetch semantics for poke operations * docs: add tlon changelog fragment * style: format tlon monitor * fix: align tlon lockfile and sse id generation * docs: fix onboarding markdown list spacing --------- Co-authored-by: Josh Lehman <josh@martian.engineering>
343 lines
10 KiB
TypeScript
343 lines
10 KiB
TypeScript
import { normalizeShip } from "../targets.js";
|
|
|
|
// Cite types for message references
|
|
export interface ChanCite {
|
|
chan: { nest: string; where: string };
|
|
}
|
|
export interface GroupCite {
|
|
group: string;
|
|
}
|
|
export interface DeskCite {
|
|
desk: { flag: string; where: string };
|
|
}
|
|
export interface BaitCite {
|
|
bait: { group: string; graph: string; where: string };
|
|
}
|
|
export type Cite = ChanCite | GroupCite | DeskCite | BaitCite;
|
|
|
|
export interface ParsedCite {
|
|
type: "chan" | "group" | "desk" | "bait";
|
|
nest?: string;
|
|
author?: string;
|
|
postId?: string;
|
|
group?: string;
|
|
flag?: string;
|
|
where?: string;
|
|
}
|
|
|
|
// Extract all cites from message content
|
|
export function extractCites(content: unknown): ParsedCite[] {
|
|
if (!content || !Array.isArray(content)) {
|
|
return [];
|
|
}
|
|
|
|
const cites: ParsedCite[] = [];
|
|
|
|
for (const verse of content) {
|
|
if (verse?.block?.cite && typeof verse.block.cite === "object") {
|
|
const cite = verse.block.cite;
|
|
|
|
if (cite.chan && typeof cite.chan === "object") {
|
|
const { nest, where } = cite.chan;
|
|
const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
|
|
cites.push({
|
|
type: "chan",
|
|
nest,
|
|
where,
|
|
author: whereMatch?.[1],
|
|
postId: whereMatch?.[2],
|
|
});
|
|
} else if (cite.group && typeof cite.group === "string") {
|
|
cites.push({ type: "group", group: cite.group });
|
|
} else if (cite.desk && typeof cite.desk === "object") {
|
|
cites.push({ type: "desk", flag: cite.desk.flag, where: cite.desk.where });
|
|
} else if (cite.bait && typeof cite.bait === "object") {
|
|
cites.push({
|
|
type: "bait",
|
|
group: cite.bait.group,
|
|
nest: cite.bait.graph,
|
|
where: cite.bait.where,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return cites;
|
|
}
|
|
|
|
export function formatModelName(modelString?: string | null): string {
|
|
if (!modelString) {
|
|
return "AI";
|
|
}
|
|
const modelName = modelString.includes("/") ? modelString.split("/")[1] : modelString;
|
|
const modelMappings: Record<string, string> = {
|
|
"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",
|
|
};
|
|
|
|
if (modelMappings[modelName]) {
|
|
return modelMappings[modelName];
|
|
}
|
|
return modelName
|
|
.replace(/-/g, " ")
|
|
.split(" ")
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
export function isBotMentioned(
|
|
messageText: string,
|
|
botShipName: string,
|
|
nickname?: string,
|
|
): boolean {
|
|
if (!messageText || !botShipName) {
|
|
return false;
|
|
}
|
|
|
|
// Check for @all mention
|
|
if (/@all\b/i.test(messageText)) {
|
|
return true;
|
|
}
|
|
|
|
// Check for ship mention
|
|
const normalizedBotShip = normalizeShip(botShipName);
|
|
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i");
|
|
if (mentionPattern.test(messageText)) {
|
|
return true;
|
|
}
|
|
|
|
// Check for nickname mention (case-insensitive, word boundary)
|
|
if (nickname) {
|
|
const escapedNickname = nickname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const nicknamePattern = new RegExp(`(^|\\s)${escapedNickname}(?=\\s|$|[,!?.])`, "i");
|
|
if (nicknamePattern.test(messageText)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Strip bot ship mention from message text for command detection.
|
|
* "~bot-ship /status" → "/status"
|
|
*/
|
|
export function stripBotMention(messageText: string, botShipName: string): string {
|
|
if (!messageText || !botShipName) return messageText;
|
|
return messageText.replace(normalizeShip(botShipName), "").trim();
|
|
}
|
|
|
|
export function isDmAllowed(senderShip: string, allowlist: string[] | undefined): boolean {
|
|
if (!allowlist || allowlist.length === 0) {
|
|
return false;
|
|
}
|
|
const normalizedSender = normalizeShip(senderShip);
|
|
return allowlist.map((ship) => normalizeShip(ship)).some((ship) => ship === normalizedSender);
|
|
}
|
|
|
|
/**
|
|
* Check if a group invite from a ship should be auto-accepted.
|
|
*
|
|
* SECURITY: Fail-safe to deny. If allowlist is empty or undefined,
|
|
* ALL invites are rejected - even if autoAcceptGroupInvites is enabled.
|
|
* This prevents misconfigured bots from accepting malicious invites.
|
|
*/
|
|
export function isGroupInviteAllowed(
|
|
inviterShip: string,
|
|
allowlist: string[] | undefined,
|
|
): boolean {
|
|
// SECURITY: Fail-safe to deny when no allowlist configured
|
|
if (!allowlist || allowlist.length === 0) {
|
|
return false;
|
|
}
|
|
const normalizedInviter = normalizeShip(inviterShip);
|
|
return allowlist.map((ship) => normalizeShip(ship)).some((ship) => ship === normalizedInviter);
|
|
}
|
|
|
|
// Helper to recursively extract text from inline content
|
|
function extractInlineText(items: any[]): string {
|
|
return items
|
|
.map((item: any) => {
|
|
if (typeof item === "string") {
|
|
return item;
|
|
}
|
|
if (item && typeof item === "object") {
|
|
if (item.ship) {
|
|
return item.ship;
|
|
}
|
|
if ("sect" in item) {
|
|
return `@${item.sect || "all"}`;
|
|
}
|
|
if (item["inline-code"]) {
|
|
return `\`${item["inline-code"]}\``;
|
|
}
|
|
if (item.code) {
|
|
return `\`${item.code}\``;
|
|
}
|
|
if (item.link && item.link.href) {
|
|
return item.link.content || item.link.href;
|
|
}
|
|
if (item.bold && Array.isArray(item.bold)) {
|
|
return `**${extractInlineText(item.bold)}**`;
|
|
}
|
|
if (item.italics && Array.isArray(item.italics)) {
|
|
return `*${extractInlineText(item.italics)}*`;
|
|
}
|
|
if (item.strike && Array.isArray(item.strike)) {
|
|
return `~~${extractInlineText(item.strike)}~~`;
|
|
}
|
|
}
|
|
return "";
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
export function extractMessageText(content: unknown): string {
|
|
if (!content || !Array.isArray(content)) {
|
|
return "";
|
|
}
|
|
|
|
return content
|
|
.map((verse: any) => {
|
|
// Handle inline content (text, ships, links, etc.)
|
|
if (verse.inline && Array.isArray(verse.inline)) {
|
|
return verse.inline
|
|
.map((item: any) => {
|
|
if (typeof item === "string") {
|
|
return item;
|
|
}
|
|
if (item && typeof item === "object") {
|
|
if (item.ship) {
|
|
return item.ship;
|
|
}
|
|
// Handle sect (role mentions like @all)
|
|
if ("sect" in item) {
|
|
return `@${item.sect || "all"}`;
|
|
}
|
|
if (item.break !== undefined) {
|
|
return "\n";
|
|
}
|
|
if (item.link && item.link.href) {
|
|
return item.link.href;
|
|
}
|
|
// Handle inline code (Tlon uses "inline-code" key)
|
|
if (item["inline-code"]) {
|
|
return `\`${item["inline-code"]}\``;
|
|
}
|
|
if (item.code) {
|
|
return `\`${item.code}\``;
|
|
}
|
|
// Handle bold/italic/strike - recursively extract text
|
|
if (item.bold && Array.isArray(item.bold)) {
|
|
return `**${extractInlineText(item.bold)}**`;
|
|
}
|
|
if (item.italics && Array.isArray(item.italics)) {
|
|
return `*${extractInlineText(item.italics)}*`;
|
|
}
|
|
if (item.strike && Array.isArray(item.strike)) {
|
|
return `~~${extractInlineText(item.strike)}~~`;
|
|
}
|
|
// Handle blockquote inline
|
|
if (item.blockquote && Array.isArray(item.blockquote)) {
|
|
return `> ${extractInlineText(item.blockquote)}`;
|
|
}
|
|
}
|
|
return "";
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
// Handle block content (images, code blocks, etc.)
|
|
if (verse.block && typeof verse.block === "object") {
|
|
const block = verse.block;
|
|
|
|
// Image blocks
|
|
if (block.image && block.image.src) {
|
|
const alt = block.image.alt ? ` (${block.image.alt})` : "";
|
|
return `\n${block.image.src}${alt}\n`;
|
|
}
|
|
|
|
// Code blocks
|
|
if (block.code && typeof block.code === "object") {
|
|
const lang = block.code.lang || "";
|
|
const code = block.code.code || "";
|
|
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
|
|
}
|
|
|
|
// Header blocks
|
|
if (block.header && typeof block.header === "object") {
|
|
const text =
|
|
block.header.content
|
|
?.map((item: any) => (typeof item === "string" ? item : ""))
|
|
.join("") || "";
|
|
return `\n## ${text}\n`;
|
|
}
|
|
|
|
// Cite/quote blocks - parse the reference structure
|
|
if (block.cite && typeof block.cite === "object") {
|
|
const cite = block.cite;
|
|
|
|
// ChanCite - reference to a channel message
|
|
if (cite.chan && typeof cite.chan === "object") {
|
|
const { nest, where } = cite.chan;
|
|
// where is typically /msg/~author/timestamp
|
|
const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
|
|
if (whereMatch) {
|
|
const [, author, _postId] = whereMatch;
|
|
return `\n> [quoted: ${author} in ${nest}]\n`;
|
|
}
|
|
return `\n> [quoted from ${nest}]\n`;
|
|
}
|
|
|
|
// GroupCite - reference to a group
|
|
if (cite.group && typeof cite.group === "string") {
|
|
return `\n> [ref: group ${cite.group}]\n`;
|
|
}
|
|
|
|
// DeskCite - reference to an app/desk
|
|
if (cite.desk && typeof cite.desk === "object") {
|
|
return `\n> [ref: ${cite.desk.flag}]\n`;
|
|
}
|
|
|
|
// BaitCite - reference with group+graph context
|
|
if (cite.bait && typeof cite.bait === "object") {
|
|
return `\n> [ref: ${cite.bait.graph} in ${cite.bait.group}]\n`;
|
|
}
|
|
|
|
return `\n> [quoted message]\n`;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
})
|
|
.join("\n")
|
|
.trim();
|
|
}
|
|
|
|
export function isSummarizationRequest(messageText: string): boolean {
|
|
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));
|
|
}
|
|
|
|
export function formatChangesDate(daysAgo = 5): string {
|
|
const now = new Date();
|
|
const targetDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
|
|
const year = targetDate.getFullYear();
|
|
const month = targetDate.getMonth() + 1;
|
|
const day = targetDate.getDate();
|
|
return `~${year}.${month}.${day}..20.19.51..9b9d`;
|
|
}
|