From a00a51e0fcae0b59258b23a8c0194a2240027f4c Mon Sep 17 00:00:00 2001 From: hatsunemiku Date: Fri, 22 May 2026 16:03:07 +0000 Subject: [PATCH] Remove skills redundant with native hostagent integrations Removes: - External/notion (covered by use_app_notion) - Official/zo-google-direct-oauth (covered by native Google OAuth) - Community/google-calendar (depended on zo-google-direct-oauth) - External/gog (Google Workspace CLI, covered by use_app_google_*) - Connections/zo-twitter (covered by use_app_x + x_search) - Community/twitter-api (covered by use_app_x) - Connections/zo-linkedin (low quality, LinkedIn available via Pipedream) --- Community/google-calendar/DISPLAY.json | 10 - Community/google-calendar/SKILL.md | 82 ----- Community/google-calendar/scripts/gcal.py | 334 ------------------ Community/twitter-api/DISPLAY.json | 23 -- Community/twitter-api/SKILL.md | 61 ---- Community/twitter-api/scripts/bun.lock | 31 -- Community/twitter-api/scripts/package.json | 16 - Community/twitter-api/scripts/x.ts | 312 ---------------- Connections/zo-linkedin/DISPLAY.json | 15 - Connections/zo-linkedin/SKILL.md | 39 -- Connections/zo-linkedin/scripts/lk.py | 133 ------- Connections/zo-twitter/DISPLAY.json | 10 - Connections/zo-twitter/SKILL.md | 195 ---------- External/gog/DISPLAY.json | 13 - External/gog/SKILL.md | 111 ------ External/notion/DISPLAY.json | 11 - External/notion/SKILL.md | 161 --------- Official/zo-google-direct-oauth/DISPLAY.json | 10 - Official/zo-google-direct-oauth/SKILL.md | 119 ------- .../references/api-notes.md | 94 ----- .../scripts/google_auth.py | 90 ----- .../scripts/oauth-server.ts | 137 ------- .../scripts/refresh-daemon.py | 76 ---- .../zo-google-direct-oauth/scripts/setup.py | 54 --- 24 files changed, 2137 deletions(-) delete mode 100644 Community/google-calendar/DISPLAY.json delete mode 100644 Community/google-calendar/SKILL.md delete mode 100644 Community/google-calendar/scripts/gcal.py delete mode 100644 Community/twitter-api/DISPLAY.json delete mode 100644 Community/twitter-api/SKILL.md delete mode 100644 Community/twitter-api/scripts/bun.lock delete mode 100644 Community/twitter-api/scripts/package.json delete mode 100644 Community/twitter-api/scripts/x.ts delete mode 100644 Connections/zo-linkedin/DISPLAY.json delete mode 100644 Connections/zo-linkedin/SKILL.md delete mode 100644 Connections/zo-linkedin/scripts/lk.py delete mode 100644 Connections/zo-twitter/DISPLAY.json delete mode 100644 Connections/zo-twitter/SKILL.md delete mode 100644 External/gog/DISPLAY.json delete mode 100644 External/gog/SKILL.md delete mode 100644 External/notion/DISPLAY.json delete mode 100644 External/notion/SKILL.md delete mode 100644 Official/zo-google-direct-oauth/DISPLAY.json delete mode 100644 Official/zo-google-direct-oauth/SKILL.md delete mode 100644 Official/zo-google-direct-oauth/references/api-notes.md delete mode 100644 Official/zo-google-direct-oauth/scripts/google_auth.py delete mode 100644 Official/zo-google-direct-oauth/scripts/oauth-server.ts delete mode 100644 Official/zo-google-direct-oauth/scripts/refresh-daemon.py delete mode 100644 Official/zo-google-direct-oauth/scripts/setup.py diff --git a/Community/google-calendar/DISPLAY.json b/Community/google-calendar/DISPLAY.json deleted file mode 100644 index 6a845b7..0000000 --- a/Community/google-calendar/DISPLAY.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "icon": "calendar", - "tags": [ - "productivity", - "automation" - ], - "integrations": [ - "google_calendar" - ] -} diff --git a/Community/google-calendar/SKILL.md b/Community/google-calendar/SKILL.md deleted file mode 100644 index 6883121..0000000 --- a/Community/google-calendar/SKILL.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -name: google-calendar -description: | - Query Google Calendar for events and free time blocks. Requires google-direct-oauth skill to be - set up first. Ask Zo things like "what's on my calendar today", "find free time tomorrow", - "show my week", or "when am I free on Friday between 10am and 4pm". - -compatibility: Requires zo-google-direct-oauth skill with valid tokens at /home/.z/google-oauth/ -metadata: - author: rob.zo.computer - category: Community - emoji: 🅖 ---- - -# Google Calendar Skill - -Query your calendar and find free time using your direct Google OAuth connection. - -## Prerequisites - -Set up the `google-direct-oauth` skill first. You should have valid tokens at `/home/.z/google-oauth/token.json`. -These credentials can also be reused by `gog` if you install that external skill. - -## Usage - -The main script is `scripts/gcal.py`. Run with `--help` for all options: - -```bash -python Skills/google-calendar/scripts/gcal.py --help -``` - -### List Events - -```bash -# Today's events -python scripts/gcal.py events - -# Specific date -python scripts/gcal.py events 2026-01-25 -python scripts/gcal.py events tomorrow -python scripts/gcal.py events "next monday" -``` - -### Find Free Time - -```bash -# Free blocks today (9am-6pm, min 15 min) -python scripts/gcal.py free - -# Custom hours -python scripts/gcal.py free tomorrow --start 10 --end 16 - -# Longer blocks only -python scripts/gcal.py free friday --min-duration 60 -``` - -### Week Overview - -```bash -python scripts/gcal.py week -``` - -### JSON Output (for programmatic use) - -```bash -python scripts/gcal.py json today -``` - -## For Zo - -When the user asks about their calendar: - -1. **"What's on my calendar [date]?"** → Run `events` command -2. **"When am I free [date]?"** → Run `free` command -3. **"Show my week"** → Run `week` command -4. **"Find me a 1-hour slot tomorrow afternoon"** → Run `free tomorrow --start 12 --end 18 --min-duration 60` - -Parse natural language dates: -- "today", "tomorrow", "yesterday" -- "next monday", "next friday" -- "January 25" → "01-25" -- "2026-01-25" diff --git a/Community/google-calendar/scripts/gcal.py b/Community/google-calendar/scripts/gcal.py deleted file mode 100644 index 0427a13..0000000 --- a/Community/google-calendar/scripts/gcal.py +++ /dev/null @@ -1,334 +0,0 @@ -#!/usr/bin/env python3 -""" -Google Calendar CLI -Query events and find free time blocks. - -Usage: - python calendar.py events [DATE] # List events (default: today) - python calendar.py events 2026-01-25 - python calendar.py events tomorrow - python calendar.py events "next monday" - - python calendar.py free [DATE] # Find free time blocks - python calendar.py free today --start 9 --end 18 - python calendar.py free 2026-01-25 --min-duration 30 - - python calendar.py week # Show this week's events -""" - -import sys -sys.path.insert(0, "/home/.z/google-oauth") - -import argparse -import json -from datetime import datetime, timedelta, time as dt_time -from zoneinfo import ZoneInfo -from google_auth import get_calendar_service - -DEFAULT_TZ = ZoneInfo("America/New_York") - -def parse_date(date_str: str) -> datetime: - """Parse flexible date input.""" - date_str = date_str.lower().strip() - today = datetime.now(DEFAULT_TZ).replace(hour=0, minute=0, second=0, microsecond=0) - - if date_str in ("today", ""): - return today - elif date_str == "tomorrow": - return today + timedelta(days=1) - elif date_str == "yesterday": - return today - timedelta(days=1) - elif date_str.startswith("next "): - day_name = date_str[5:] - days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] - if day_name in days: - target = days.index(day_name) - current = today.weekday() - delta = (target - current) % 7 - if delta == 0: - delta = 7 - return today + timedelta(days=delta) - - # Try parsing as YYYY-MM-DD - try: - return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=DEFAULT_TZ) - except ValueError: - pass - - # Try parsing as MM-DD - try: - parsed = datetime.strptime(date_str, "%m-%d") - return parsed.replace(year=today.year, tzinfo=DEFAULT_TZ) - except ValueError: - pass - - raise ValueError(f"Could not parse date: {date_str}") - -def get_events(date: datetime, calendar_id: str = "primary") -> list: - """Fetch events for a specific day.""" - service = get_calendar_service() - - start = date.replace(hour=0, minute=0, second=0, microsecond=0) - end = start + timedelta(days=1) - - events = service.events().list( - calendarId=calendar_id, - timeMin=start.isoformat(), - timeMax=end.isoformat(), - singleEvents=True, - orderBy="startTime" - ).execute() - - return events.get("items", []) - -def get_week_events(calendar_id: str = "primary") -> list: - """Fetch events for the current week.""" - service = get_calendar_service() - - today = datetime.now(DEFAULT_TZ).replace(hour=0, minute=0, second=0, microsecond=0) - start_of_week = today - timedelta(days=today.weekday()) - end_of_week = start_of_week + timedelta(days=7) - - events = service.events().list( - calendarId=calendar_id, - timeMin=start_of_week.isoformat(), - timeMax=end_of_week.isoformat(), - singleEvents=True, - orderBy="startTime" - ).execute() - - return events.get("items", []) - -def parse_event_time(event: dict, key: str) -> datetime | None: - """Extract datetime from event start/end.""" - time_info = event.get(key, {}) - - if "dateTime" in time_info: - dt_str = time_info["dateTime"] - # Handle timezone offset - return datetime.fromisoformat(dt_str).astimezone(DEFAULT_TZ) - elif "date" in time_info: - # All-day event - return None - - return None - -def find_free_blocks( - date: datetime, - day_start_hour: int = 9, - day_end_hour: int = 18, - min_duration_minutes: int = 15, - calendar_id: str = "primary" -) -> list: - """Find free time blocks on a given day.""" - events = get_events(date, calendar_id) - - day_start = date.replace(hour=day_start_hour, minute=0, second=0, microsecond=0) - day_end = date.replace(hour=day_end_hour, minute=0, second=0, microsecond=0) - - # Collect busy periods - busy = [] - for event in events: - start = parse_event_time(event, "start") - end = parse_event_time(event, "end") - - if start is None: # All-day event - whole day is busy - return [] - - # Clamp to work hours - start = max(start, day_start) - end = min(end, day_end) - - if start < end: - busy.append((start, end)) - - # Sort by start time - busy.sort(key=lambda x: x[0]) - - # Merge overlapping periods - merged = [] - for start, end in busy: - if merged and start <= merged[-1][1]: - merged[-1] = (merged[-1][0], max(merged[-1][1], end)) - else: - merged.append((start, end)) - - # Find gaps - free = [] - cursor = day_start - - for busy_start, busy_end in merged: - if cursor < busy_start: - gap_minutes = (busy_start - cursor).seconds // 60 - if gap_minutes >= min_duration_minutes: - free.append((cursor, busy_start)) - cursor = max(cursor, busy_end) - - # Check end of day - if cursor < day_end: - gap_minutes = (day_end - cursor).seconds // 60 - if gap_minutes >= min_duration_minutes: - free.append((cursor, day_end)) - - return free - -def format_time(dt: datetime) -> str: - """Format datetime for display.""" - return dt.strftime("%-I:%M%p").lower() - -def format_duration(start: datetime, end: datetime) -> str: - """Format duration in hours/minutes.""" - minutes = int((end - start).total_seconds() // 60) - if minutes >= 60: - hours = minutes // 60 - mins = minutes % 60 - if mins: - return f"{hours}h {mins}m" - return f"{hours}h" - return f"{minutes}m" - -def cmd_events(args): - """List events for a day.""" - date = parse_date(args.date or "today") - events = get_events(date) - - print(f"📅 {date.strftime('%A, %B %-d, %Y')}\n") - - if not events: - print(" No events scheduled.") - return - - for event in events: - summary = event.get("summary", "(No title)") - start = parse_event_time(event, "start") - end = parse_event_time(event, "end") - - if start: - time_str = f"{format_time(start)} - {format_time(end)}" - duration = format_duration(start, end) - print(f" • {time_str} ({duration}): {summary}") - else: - print(f" • All day: {summary}") - -def cmd_free(args): - """Find free time blocks.""" - date = parse_date(args.date or "today") - free_blocks = find_free_blocks( - date, - day_start_hour=args.start, - day_end_hour=args.end, - min_duration_minutes=args.min_duration - ) - - print(f"🕐 Free time on {date.strftime('%A, %B %-d, %Y')}") - print(f" (Working hours: {args.start}:00 - {args.end}:00, min block: {args.min_duration}m)\n") - - if not free_blocks: - print(" No free blocks found.") - return - - total_free = 0 - for start, end in free_blocks: - duration = format_duration(start, end) - minutes = int((end - start).total_seconds() // 60) - total_free += minutes - print(f" ✓ {format_time(start)} - {format_time(end)} ({duration})") - - print(f"\n Total free: {format_duration(date, date + timedelta(minutes=total_free))}") - -def cmd_week(args): - """Show week overview.""" - events = get_week_events() - - today = datetime.now(DEFAULT_TZ).replace(hour=0, minute=0, second=0, microsecond=0) - start_of_week = today - timedelta(days=today.weekday()) - - print(f"📅 Week of {start_of_week.strftime('%B %-d, %Y')}\n") - - # Group by day - by_day = {} - for event in events: - start = parse_event_time(event, "start") - if start: - day = start.date() - else: - day = datetime.fromisoformat(event["start"]["date"]).date() - - if day not in by_day: - by_day[day] = [] - by_day[day].append(event) - - for i in range(7): - day = (start_of_week + timedelta(days=i)).date() - day_name = day.strftime("%A %-m/%-d") - is_today = day == today.date() - marker = " ← today" if is_today else "" - - print(f" {day_name}{marker}") - - if day in by_day: - for event in by_day[day]: - summary = event.get("summary", "(No title)") - start = parse_event_time(event, "start") - if start: - print(f" • {format_time(start)}: {summary}") - else: - print(f" • All day: {summary}") - else: - print(" (free)") - print() - -def cmd_json(args): - """Output events as JSON for programmatic use.""" - date = parse_date(args.date or "today") - events = get_events(date) - - output = [] - for event in events: - output.append({ - "id": event.get("id"), - "summary": event.get("summary"), - "start": event.get("start"), - "end": event.get("end"), - "location": event.get("location"), - "description": event.get("description"), - }) - - print(json.dumps(output, indent=2)) - -def main(): - parser = argparse.ArgumentParser(description="Google Calendar CLI") - subparsers = parser.add_subparsers(dest="command", help="Command") - - # events command - events_parser = subparsers.add_parser("events", help="List events for a day") - events_parser.add_argument("date", nargs="?", default="today", help="Date (today, tomorrow, YYYY-MM-DD, etc.)") - events_parser.set_defaults(func=cmd_events) - - # free command - free_parser = subparsers.add_parser("free", help="Find free time blocks") - free_parser.add_argument("date", nargs="?", default="today", help="Date to check") - free_parser.add_argument("--start", type=int, default=9, help="Day start hour (default: 9)") - free_parser.add_argument("--end", type=int, default=18, help="Day end hour (default: 18)") - free_parser.add_argument("--min-duration", type=int, default=15, help="Minimum block duration in minutes (default: 15)") - free_parser.set_defaults(func=cmd_free) - - # week command - week_parser = subparsers.add_parser("week", help="Show week overview") - week_parser.set_defaults(func=cmd_week) - - # json command - json_parser = subparsers.add_parser("json", help="Output events as JSON") - json_parser.add_argument("date", nargs="?", default="today", help="Date") - json_parser.set_defaults(func=cmd_json) - - args = parser.parse_args() - - if not args.command: - parser.print_help() - return - - args.func(args) - -if __name__ == "__main__": - main() diff --git a/Community/twitter-api/DISPLAY.json b/Community/twitter-api/DISPLAY.json deleted file mode 100644 index 5d672f1..0000000 --- a/Community/twitter-api/DISPLAY.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "icon": "x", - "tags": ["automation", "developer-tools", "social"], - "integrations": ["x"], - "secrets": [ - { - "secret_name": "X_API_KEY", - "description": "From the X developer portal › your project › Keys and tokens (API Key)." - }, - { - "secret_name": "X_API_KEY_SECRET", - "description": "From the X developer portal › your project › Keys and tokens (API Key Secret)." - }, - { - "secret_name": "X_ACCESS_TOKEN", - "description": "From the X developer portal › your project › Keys and tokens (Access Token)." - }, - { - "secret_name": "X_ACCESS_TOKEN_SECRET", - "description": "From the X developer portal › your project › Keys and tokens (Access Token Secret)." - } - ] -} diff --git a/Community/twitter-api/SKILL.md b/Community/twitter-api/SKILL.md deleted file mode 100644 index 758e55d..0000000 --- a/Community/twitter-api/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -name: twitter-api -description: Post, quote, reply to, and delete X (Twitter) tweets via the local X CLI script using API keys. Use when the user asks to tweet, quote tweet, reply, or delete on X/Twitter. -metadata: - author: 0.zo.computer - category: Community - display-name: Post to X (Twitter) - emoji: 🐦 ---- - -# X (Twitter) API CLI - -Post, quote, reply to, and delete tweets using the local X CLI script in `Community/twitter-api/scripts/`. - -## Setup - -The USER must complete these steps in the X Developer Portal: - -1. Sign in with their X account. -2. Create a new project (or use the default). -3. Create a new app under the project. -4. Change the app permissions to **Read & Write** before generating keys. -5. Go to the app's "Keys and Tokens" tab and generate: - - API Key and Secret (Consumer Keys) - - Access Token and Secret (User Context) - -Then the USER must set secrets in [Settings > Developers](/settings#developers) or export them locally: -- `X_API_KEY` -- `X_API_KEY_SECRET` (or `X_API_SECRET`) -- `X_ACCESS_TOKEN` -- `X_ACCESS_TOKEN_SECRET` (or `X_ACCESS_SECRET`) - -Optional local setup: copy `scripts/.env.example` to `scripts/.env` and fill in values. - -If required keys are missing, stop and ask the user to configure them before posting. - -## Commands - -Run from `Community/twitter-api/scripts/`: - -```bash -bun x.ts post "Your tweet text here" -bun x.ts quote https://x.com/user/status/123 "Your commentary" -bun x.ts reply https://x.com/user/status/123 "Your reply text" -bun x.ts delete https://x.com/user/status/123 -``` - -### Media - -Attach media by repeating `--media`: - -```bash -bun x.ts post --media /path/to/image.png "Caption" -bun x.ts quote https://x.com/user/status/123 --media /path/to/image.png "Commentary" -``` - -## Notes - -- Max length is 280 characters. If over, trim before posting. -- Use actual line breaks in the shell for multi-paragraph tweets. -- Tweet IDs or full URLs are accepted for quote/reply/delete. diff --git a/Community/twitter-api/scripts/bun.lock b/Community/twitter-api/scripts/bun.lock deleted file mode 100644 index 2fb5a10..0000000 --- a/Community/twitter-api/scripts/bun.lock +++ /dev/null @@ -1,31 +0,0 @@ -{ - "lockfileVersion": 1, - "workspaces": { - "": { - "name": "x-cli", - "dependencies": { - "crypto-js": "^4.2.0", - "oauth-1.0a": "^2.2.6", - }, - "devDependencies": { - "@types/bun": "^1.1.0", - "@types/crypto-js": "^4.2.2", - }, - }, - }, - "packages": { - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], - - "@types/crypto-js": ["@types/crypto-js@4.2.2", "", {}, "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="], - - "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], - - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], - - "crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="], - - "oauth-1.0a": ["oauth-1.0a@2.2.6", "", {}, "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ=="], - - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - } -} diff --git a/Community/twitter-api/scripts/package.json b/Community/twitter-api/scripts/package.json deleted file mode 100644 index d17c72f..0000000 --- a/Community/twitter-api/scripts/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "x-cli", - "version": "0.1.0", - "type": "module", - "scripts": { - "post": "bun x.ts post" - }, - "dependencies": { - "oauth-1.0a": "^2.2.6", - "crypto-js": "^4.2.0" - }, - "devDependencies": { - "@types/bun": "^1.1.0", - "@types/crypto-js": "^4.2.2" - } -} diff --git a/Community/twitter-api/scripts/x.ts b/Community/twitter-api/scripts/x.ts deleted file mode 100644 index ef30eae..0000000 --- a/Community/twitter-api/scripts/x.ts +++ /dev/null @@ -1,312 +0,0 @@ -#!/usr/bin/env bun - -import OAuth from "oauth-1.0a"; -import CryptoJS from "crypto-js"; -import path from "path"; - -// --- Config --- - -interface XConfig { - apiKey: string; - apiSecret: string; - accessToken: string; - accessSecret: string; -} - -function getConfig(): XConfig { - const apiKey = process.env.X_API_KEY; - const apiSecret = process.env.X_API_KEY_SECRET || process.env.X_API_SECRET; - const accessToken = process.env.X_ACCESS_TOKEN; - const accessSecret = process.env.X_ACCESS_TOKEN_SECRET || process.env.X_ACCESS_SECRET; - - if (!apiKey || !apiSecret || !accessToken || !accessSecret) { - console.error("Missing required environment variables:"); - if (!apiKey) console.error(" - X_API_KEY"); - if (!apiSecret) console.error(" - X_API_KEY_SECRET (or X_API_SECRET)"); - if (!accessToken) console.error(" - X_ACCESS_TOKEN"); - if (!accessSecret) console.error(" - X_ACCESS_TOKEN_SECRET (or X_ACCESS_SECRET)"); - console.error("\nSet them in .env or export them to your shell."); - process.exit(1); - } - - return { apiKey, apiSecret, accessToken, accessSecret }; -} - -function createOauth(config: XConfig) { - return new OAuth({ - consumer: { key: config.apiKey, secret: config.apiSecret }, - signature_method: "HMAC-SHA1", - hash_function: (baseString, key) => - CryptoJS.HmacSHA1(baseString, key).toString(CryptoJS.enc.Base64), - }); -} - -// --- Helpers --- - -function extractTweetId(input: string): string { - const match = input.match(/\/status\/(\d+)/); - return match ? match[1] : input; -} - -function parseTextAndMedia(args: string[]) { - const mediaPaths: string[] = []; - let i = 0; - while (i < args.length) { - const token = args[i]; - - if (token === "--media") { - const pathArg = args[i + 1]; - if (!pathArg) { - console.error("Usage error: --media must be followed by a file path."); - process.exit(1); - } - mediaPaths.push(pathArg); - i += 2; - continue; - } - - return { - text: args.slice(i).join(" "), - mediaPaths, - }; - } - - return { text: "", mediaPaths }; -} - -async function uploadMediaPath(filePath: string, config: XConfig) { - const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); - const arrayBuffer = await Bun.file(resolved).arrayBuffer(); - const mediaData = Buffer.from(arrayBuffer).toString("base64"); - - const oauth = createOauth(config); - const token = { key: config.accessToken, secret: config.accessSecret }; - const url = "https://upload.twitter.com/1.1/media/upload.json"; - const requestData = { url, method: "POST" as const, data: { media_data: mediaData } }; - const authHeader = oauth.toHeader(oauth.authorize(requestData, token)); - const body = new URLSearchParams({ media_data: mediaData }); - - const response = await fetch(url, { - method: "POST", - headers: { ...authHeader, "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(`X API Error (media upload): ${JSON.stringify(error, null, 2)}`); - } - - const data = await response.json(); - return data.media_id_string; -} - -async function uploadMediaPaths(paths: string[], config: XConfig) { - const mediaIds: string[] = []; - for (const mediaPath of paths) { - mediaIds.push(await uploadMediaPath(mediaPath, config)); - } - return mediaIds; -} - -// --- API --- - -interface TweetOptions { - text: string; - quoteTweetId?: string; - replyToId?: string; - mediaIds?: string[]; -} - -async function postTweet(options: TweetOptions, config: XConfig) { - const oauth = createOauth(config); - const token = { key: config.accessToken, secret: config.accessSecret }; - const url = "https://api.twitter.com/2/tweets"; - const requestData = { url, method: "POST" as const }; - const authHeader = oauth.toHeader(oauth.authorize(requestData, token)); - - const body: Record = { text: options.text }; - if (options.quoteTweetId) { - body.quote_tweet_id = options.quoteTweetId; - } - if (options.replyToId) { - body.reply = { in_reply_to_tweet_id: options.replyToId }; - } - if (options.mediaIds && options.mediaIds.length) { - body.media = { media_ids: options.mediaIds }; - } - - const response = await fetch(url, { - method: "POST", - headers: { ...authHeader, "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(`X API Error: ${JSON.stringify(error, null, 2)}`); - } - - return response.json(); -} - -async function deleteTweet(tweetId: string, config: XConfig) { - const oauth = createOauth(config); - const token = { key: config.accessToken, secret: config.accessSecret }; - const url = `https://api.twitter.com/2/tweets/${tweetId}`; - const requestData = { url, method: "DELETE" as const }; - const authHeader = oauth.toHeader(oauth.authorize(requestData, token)); - - const response = await fetch(url, { - method: "DELETE", - headers: { ...authHeader }, - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(`X API Error: ${JSON.stringify(error, null, 2)}`); - } - - return response.json(); -} - -// --- CLI --- - -const HELP = ` -x-cli - Simple X (Twitter) posting CLI - -Usage: - x post [--media ] Post a tweet (attach images by repeating --media) - x quote [--media ] Quote tweet with commentary - x reply [--media ] Reply to a tweet - x delete Delete a tweet - x help Show this help - -Examples: - x post "Hello from CLI" - x post --media /home/workspace/Images/pegasus_crossroads.png "Zo Computer + art" - x quote https://x.com/user/status/123 "Great point!" - x reply https://x.com/user/status/123 "Thanks for sharing" - x delete https://x.com/user/status/123 - -Note: Use actual line breaks in your text: - x quote https://x.com/user/status/123 "First paragraph. - -Second paragraph." - -Max length: 280 characters -`; - -const args = process.argv.slice(2); -const command = args[0]; - -if (!command || command === "help" || command === "-h") { - console.log(HELP); - process.exit(0); -} - -const config = getConfig(); - -function assertText(text: string, usage: string) { - if (!text) { - console.error(usage); - process.exit(1); - } - if (text.length > 280) { - console.error(`Error: Tweet is ${text.length} chars (max 280)`); - console.error(`You need to cut ${text.length - 280} characters.`); - process.exit(1); - } -} - -async function run() { - if (command === "post") { - const { text, mediaPaths } = parseTextAndMedia(args.slice(1)); - assertText(text, 'Usage: x post "Your tweet here"'); - - const mediaIds = mediaPaths.length ? await uploadMediaPaths(mediaPaths, config) : []; - - console.log(`Posting: "${text}"`); - try { - const result = await postTweet({ text, mediaIds }, config); - console.log("✓ Posted!"); - console.log(` https://x.com/i/status/${result.data.id}`); - } catch (error) { - console.error("✗ Failed:", error); - process.exit(1); - } - } else if (command === "quote") { - const tweetIdOrUrl = args[1]; - if (!tweetIdOrUrl) { - console.error('Usage: x quote "Your commentary here"'); - process.exit(1); - } - - const { text, mediaPaths } = parseTextAndMedia(args.slice(2)); - assertText(text, 'Usage: x quote "Your commentary here"'); - - const quoteTweetId = extractTweetId(tweetIdOrUrl); - const mediaIds = mediaPaths.length ? await uploadMediaPaths(mediaPaths, config) : []; - - console.log(`Quoting tweet ${quoteTweetId}: "${text}"`); - try { - const result = await postTweet({ text, quoteTweetId, mediaIds }, config); - console.log("✓ Quote tweeted!"); - console.log(` https://x.com/i/status/${result.data.id}`); - } catch (error) { - console.error("✗ Failed:", error); - process.exit(1); - } - } else if (command === "reply") { - const tweetIdOrUrl = args[1]; - if (!tweetIdOrUrl) { - console.error('Usage: x reply "Your reply here"'); - process.exit(1); - } - - const { text, mediaPaths } = parseTextAndMedia(args.slice(2)); - assertText(text, 'Usage: x reply "Your reply here"'); - - const replyToId = extractTweetId(tweetIdOrUrl); - const mediaIds = mediaPaths.length ? await uploadMediaPaths(mediaPaths, config) : []; - - console.log(`Replying to tweet ${replyToId}: "${text}"`); - try { - const result = await postTweet({ text, replyToId, mediaIds }, config); - console.log("✓ Replied!"); - console.log(` https://x.com/i/status/${result.data.id}`); - } catch (error) { - console.error("✗ Failed:", error); - process.exit(1); - } - } else if (command === "delete") { - const tweetIdOrUrl = args[1]; - - if (!tweetIdOrUrl) { - console.error("Usage: x delete "); - process.exit(1); - } - - const tweetId = extractTweetId(tweetIdOrUrl); - - console.log(`Deleting tweet ${tweetId}...`); - - try { - const result = await deleteTweet(tweetId, config); - if (result.data?.deleted) { - console.log("✓ Deleted!"); - } else { - console.log("✗ Tweet may not have been deleted:", result); - } - } catch (error) { - console.error("✗ Failed:", error); - process.exit(1); - } - } else { - console.error(`Unknown command: ${command}`); - process.exit(1); - } -} - -await run(); - diff --git a/Connections/zo-linkedin/DISPLAY.json b/Connections/zo-linkedin/DISPLAY.json deleted file mode 100644 index baa57b4..0000000 --- a/Connections/zo-linkedin/DISPLAY.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "icon": "linkedin", - "tags": ["automation", "social"], - "integrations": ["linkedin"], - "secrets": [ - { - "secret_name": "LINKEDIN_LI_AT", - "description": "The `li_at` cookie value from a logged-in LinkedIn browser session (DevTools › Application › Cookies › linkedin.com)." - }, - { - "secret_name": "LINKEDIN_JSESSIONID", - "description": "The `JSESSIONID` cookie value from the same LinkedIn session. Strip surrounding quotes if present." - } - ] -} diff --git a/Connections/zo-linkedin/SKILL.md b/Connections/zo-linkedin/SKILL.md deleted file mode 100644 index 21075e8..0000000 --- a/Connections/zo-linkedin/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: linkedin -description: LinkedIn tool for searching profiles, checking messages, and summarizing your feed using session cookies. -homepage: https://github.com/clawdbot/linkedin-cli -compatibility: see metadata.clawdbot.requires -metadata: - author: Zo - category: Connections - display-name: LinkedIn ---- - -`lk`: Fast LinkedIn CLI using cookie auth. - -## Updating - -The `linkedin-api` Python package comes pre-installed on Zo. To update it: - -```bash -uv pip install --system --break-system-packages linkedin-api -``` - -## Authentication - -`lk.py` uses cookie-based auth. - -The USER must set their credentials in their Settings > Integrations > Connections page. - -If `LINKEDIN_LI_AT` and `LINKEDIN_JSESSIONID` are not set, direct the USER to this page with a relative URL. - -## Usage - -Use `scripts/lk.py` - -- `lk whoami`: Display your current profile details. -- `lk search "query"`: Search for people by keywords. -- `lk profile `: Get a detailed summary of a specific profile. -- `lk feed -n 10`: Summarize the top N posts from your timeline. -- `lk messages`: Quick peek at your recent conversations. -- `lk check`: Combined whoami and messages check. diff --git a/Connections/zo-linkedin/scripts/lk.py b/Connections/zo-linkedin/scripts/lk.py deleted file mode 100644 index 5c32bd6..0000000 --- a/Connections/zo-linkedin/scripts/lk.py +++ /dev/null @@ -1,133 +0,0 @@ -import os -import sys -import argparse -import json -from linkedin_api import Linkedin -from requests.cookies import RequestsCookieJar - -# Styling -BOLD = "\033[1m" -RESET = "\033[0m" -BLUE = "\033[94m" -GREEN = "\033[92m" - -def get_api(): - li_at = os.environ.get("LINKEDIN_LI_AT") - jsessionid = os.environ.get("LINKEDIN_JSESSIONID") - if not li_at or not jsessionid: - print("Error: LINKEDIN_LI_AT and LINKEDIN_JSESSIONID environment variables not set.") - sys.exit(1) - - jar = RequestsCookieJar() - jar.set("li_at", li_at, domain=".www.linkedin.com") - jar.set("JSESSIONID", jsessionid, domain=".www.linkedin.com") - - return Linkedin("", "", cookies=jar) - -def whoami(api): - profile = api.get_user_profile() - name = f"{profile.get('firstName', '')} {profile.get('lastName', '')}".strip() - headline = profile.get('headline', profile.get('miniProfile', {}).get('occupation', 'No headline')) - location = profile.get('locationName', 'Unknown') - print(f"{BOLD}{name}{RESET}") - print(f"{BLUE}{headline}{RESET}") - print(f"📍 {location}") - -def search(api, query): - results = api.search_people(keywords=query, limit=10) - print(f"Search results for '{BOLD}{query}{RESET}':") - for res in results: - name = res.get('name', 'Unknown') - job = res.get('jobtitle', 'No headline') - urn = res.get('urn_id', 'No URN') - print(f"- {BOLD}{name}{RESET} ({urn})") - print(f" {job}") - -def view_profile(api, public_id): - profile = api.get_profile(public_id) - name = f"{profile.get('firstName', '')} {profile.get('lastName', '')}" - headline = profile.get('headline', 'No headline') - summary = profile.get('summary', 'No summary provided.') - - print(f"{BOLD}{name}{RESET}") - print(f"{BLUE}{headline}{RESET}") - print("-" * 20) - print(summary) - - print(f"\n{BOLD}Experience:{RESET}") - for exp in profile.get('experience', [])[:3]: - company = exp.get('companyName', 'Unknown') - title = exp.get('title', 'Unknown') - print(f"• {BOLD}{title}{RESET} at {company}") - -def check_messages(api): - conversations = api.get_conversations() - print(f"{BOLD}Recent Conversations:{RESET}") - for conv in conversations.get('elements', [])[:5]: - participants = ", ".join([p.get('firstName', 'Unknown') for p in conv.get('participants', [])]) - events = conv.get('events', [{}]) - snippet = "No preview" - if events: - content = events[0].get('eventContent', {}) - msg_event = content.get('com.linkedin.voyager.messaging.event.MessageEvent', {}) - snippet = msg_event.get('body', 'No preview') - - print(f"• {BOLD}{participants}{RESET}") - print(f" {snippet[:100]}...") - -def feed(api, count=10): - posts = api.get_feed_posts(limit=count) - print(f"{BOLD}LinkedIn Feed (Top {count}):{RESET}") - for post in posts: - author = post.get('author_name', 'Unknown') - time = post.get('old', 'Recently').strip() - content = post.get('content', 'No content').replace('\n', ' ') - print(f"• {BOLD}{author}{RESET} ({time}): {content[:200]}...") - -def main(): - parser = argparse.ArgumentParser(description="lk - LinkedIn CLI") - subparsers = parser.add_subparsers(dest="command") - - subparsers.add_parser("whoami", help="Display current user profile") - - search_parser = subparsers.add_parser("search", help="Search for people") - search_parser.add_argument("query", help="Search keywords") - - profile_parser = subparsers.add_parser("profile", help="View profile details") - profile_parser.add_argument("public_id", help="Public ID or URN") - - subparsers.add_parser("messages", help="Check recent messages") - - feed_parser = subparsers.add_parser("feed", help="Summarize your timeline") - feed_parser.add_argument("-n", "--count", type=int, default=10, help="Number of posts to fetch") - - subparsers.add_parser("check", help="Quick status check") - - args = parser.parse_args() - - if not args.command: - parser.print_help() - return - - api = get_api() - - try: - if args.command == "whoami": - whoami(api) - elif args.command == "search": - search(api, args.query) - elif args.command == "profile": - view_profile(api, args.public_id) - elif args.command == "messages": - check_messages(api) - elif args.command == "feed": - feed(api, args.count) - elif args.command == "check": - whoami(api) - print("-" * 10) - check_messages(api) - except Exception as e: - print(f"{BOLD}LinkedIn Error:{RESET} {e}") - -if __name__ == "__main__": - main() diff --git a/Connections/zo-twitter/DISPLAY.json b/Connections/zo-twitter/DISPLAY.json deleted file mode 100644 index ca5f5b1..0000000 --- a/Connections/zo-twitter/DISPLAY.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "icon": "x", - "tags": [ - "automation", - "social" - ], - "integrations": [ - "x" - ] -} diff --git a/Connections/zo-twitter/SKILL.md b/Connections/zo-twitter/SKILL.md deleted file mode 100644 index f0e86a1..0000000 --- a/Connections/zo-twitter/SKILL.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -name: twitter -description: Let Zo use your X (Twitter) account -compatibility: Requires the bird CLI, pre-installed on Zo. Credentials can be set in Zo settings. -metadata: - author: Zo - category: Connections - display-name: X (Twitter) ---- - -## Zo now has a native X integration; connect your X account(s) in Settings > Integrations for the ability to post tweets and send DMs from your X account on Zo. You can also use the x_search tool to search posts on X. Use this skill for everything else. - -`bird`: Fast X/Twitter CLI using cookie auth. - -## Updating - -`bird` comes pre-installed on Zo. To update: - -```bash -npm install -g @steipete/bird -``` - -## Authentication - -`bird` uses cookie-based auth. - -The USER must set their credentials in their Settings > Integrations > Connections page. - -If `TWITTER_AUTH_TOKEN` and `TWITTER_CT0` are not set, direct the USER to this page with a relative URL. - -## Commands - -### Account & Auth - -```bash -bird whoami # Show logged-in account -bird check # Show credential sources -bird query-ids --fresh # Refresh GraphQL query ID cache -``` - -### Reading Tweets - -```bash -bird read # Read a single tweet -bird # Shorthand for read -bird thread # Full conversation thread -bird replies # List replies to a tweet -``` - -### Timelines - -```bash -bird home # Home timeline (For You) -bird home --following # Following timeline -bird user-tweets @handle -n 20 # User's profile timeline -bird mentions # Tweets mentioning you -bird mentions --user @handle # Mentions of another user -``` - -### Search - -```bash -bird search "query" -n 10 -bird search "from:steipete" --all --max-pages 3 -``` - -### News & Trending - -```bash -bird news -n 10 # AI-curated from Explore tabs -bird news --ai-only # Filter to AI-curated only -bird news --sports # Sports tab -bird news --with-tweets # Include related tweets -bird trending # Alias for news -``` - -### Lists - -```bash -bird lists # Your lists -bird lists --member-of # Lists you're a member of -bird list-timeline -n 20 # Tweets from a list -``` - -### Bookmarks & Likes - -```bash -bird bookmarks -n 10 -bird bookmarks --folder-id # Specific folder -bird bookmarks --include-parent # Include parent tweet -bird bookmarks --author-chain # Author's self-reply chain -bird bookmarks --full-chain-only # Full reply chain -bird unbookmark -bird likes -n 10 -``` - -### Social Graph - -```bash -bird following -n 20 # Users you follow -bird followers -n 20 # Users following you -bird following --user # Another user's following -bird about @handle # Account origin/location info -``` - -### Engagement Actions - -```bash -bird follow @handle # Follow a user -bird unfollow @handle # Unfollow a user -``` - -### Posting - -```bash -bird tweet "hello world" -bird reply "nice thread!" -bird tweet "check this out" --media image.png --alt "description" -``` - -**⚠️ Posting risks**: Posting is more likely to be rate limited; if blocked, use the browser tool instead. - -## Media Uploads - -```bash -bird tweet "hi" --media img.png --alt "description" -bird tweet "pics" --media a.jpg --media b.jpg # Up to 4 images -bird tweet "video" --media clip.mp4 # Or 1 video -``` - -## Pagination - -Commands supporting pagination: `replies`, `thread`, `search`, `bookmarks`, `likes`, `list-timeline`, `following`, `followers`, `user-tweets` - -```bash -bird bookmarks --all # Fetch all pages -bird bookmarks --max-pages 3 # Limit pages -bird bookmarks --cursor # Resume from cursor -bird replies --all --delay 1000 # Delay between pages (ms) -``` - -## Output Options - -```bash ---json # JSON output ---json-full # JSON with raw API response ---plain # No emoji, no color (script-friendly) ---no-emoji # Disable emoji ---no-color # Disable ANSI colors (or set NO_COLOR=1) ---quote-depth n # Max quoted tweet depth in JSON (default: 1) -``` - -## Global Options - -```bash ---auth-token # Set auth_token cookie ---ct0 # Set ct0 cookie ---cookie-source # Cookie source for browser cookies (repeatable) ---chrome-profile # Chrome profile name ---chrome-profile-dir # Chrome/Chromium profile dir or cookie DB path ---firefox-profile # Firefox profile ---timeout # Request timeout ---cookie-timeout # Cookie extraction timeout -``` - -## Config File - -`~/.config/bird/config.json5` (global) or `./.birdrc.json5` (project): - -```json5 -{ - cookieSource: ["chrome"], - chromeProfileDir: "/path/to/Arc/Profile", - timeoutMs: 20000, - quoteDepth: 1 -} -``` - -Environment variables: `BIRD_TIMEOUT_MS`, `BIRD_COOKIE_TIMEOUT_MS`, `BIRD_QUOTE_DEPTH` - -## Troubleshooting - -### Query IDs stale (404 errors) -```bash -bird query-ids --fresh -``` - -### Cookie extraction fails -- Check browser is logged into X -- Try different `--cookie-source` -- For Arc/Brave: use `--chrome-profile-dir` - ---- - -**TL;DR**: Read/search/engage with CLI. Post carefully or use browser. diff --git a/External/gog/DISPLAY.json b/External/gog/DISPLAY.json deleted file mode 100644 index c13f377..0000000 --- a/External/gog/DISPLAY.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "icon": "globe", - "tags": [ - "automation", - "productivity", - "utility" - ], - "integrations": [ - "gmail", - "google_calendar", - "google_drive" - ] -} diff --git a/External/gog/SKILL.md b/External/gog/SKILL.md deleted file mode 100644 index ba1ea77..0000000 --- a/External/gog/SKILL.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -name: gog -description: Google Workspace CLI for Gmail, Calendar, Drive, Contacts, Sheets, and Docs -homepage: https://gogcli.sh -compatibility: Requires zo-google-direct-oauth skill with valid tokens at /home/.z/google-oauth/ -metadata: - author: Clawdbot - category: External - clawdbot: {"emoji":"🎮","requires":{"bins":["gog"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gog (brew)"}]} - display-name: Google Workspace Tool ---- - -# Notice - -This skill can reuse OAuth credentials from `zo-google-direct-oauth` at `/home/.z/google-oauth/`. -Use these steps to avoid creating a second OAuth app: - -### Setup (reuse existing OAuth app) -1. Ensure `zo-google-direct-oauth` is already set up. -2. Run: - - `gog auth credentials /home/.z/google-oauth/client_secret.json` - - `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets` -3. Verify: - - `gog auth list` - -IMPORTANT: If you generate a new OAuth app here, it may replace or invalidate existing tokens. -# gog - -Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup. - -Setup (once) -- `gog auth credentials /path/to/client_secret.json` -- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets` -- `gog auth list` - -Common commands -- Gmail search: `gog gmail search 'newer_than:7d' --max 10` -- Gmail messages search (per email, ignores threading): `gog gmail messages search "in:inbox from:ryanair.com" --max 20 --account you@example.com` -- Gmail send (plain): `gog gmail send --to a@b.com --subject "Hi" --body "Hello"` -- Gmail send (multi-line): `gog gmail send --to a@b.com --subject "Hi" --body-file ./message.txt` -- Gmail send (stdin): `gog gmail send --to a@b.com --subject "Hi" --body-file -` -- Gmail send (HTML): `gog gmail send --to a@b.com --subject "Hi" --body-html "

Hello

"` -- Gmail draft: `gog gmail drafts create --to a@b.com --subject "Hi" --body-file ./message.txt` -- Gmail send draft: `gog gmail drafts send ` -- Gmail reply: `gog gmail send --to a@b.com --subject "Re: Hi" --body "Reply" --reply-to-message-id ` -- Calendar list events: `gog calendar events --from --to ` -- Calendar create event: `gog calendar create --summary "Title" --from --to ` -- Calendar create with color: `gog calendar create --summary "Title" --from --to --event-color 7` -- Calendar update event: `gog calendar update --summary "New Title" --event-color 4` -- Calendar show colors: `gog calendar colors` -- Drive search: `gog drive search "query" --max 10` -- Contacts: `gog contacts list --max 20` -- Sheets get: `gog sheets get "Tab!A1:D10" --json` -- Sheets update: `gog sheets update "Tab!A1:B2" --values-json '[["A","B"],["1","2"]]' --input USER_ENTERED` -- Sheets append: `gog sheets append "Tab!A:C" --values-json '[["x","y","z"]]' --insert INSERT_ROWS` -- Sheets clear: `gog sheets clear "Tab!A2:Z"` -- Sheets metadata: `gog sheets metadata --json` -- Docs export: `gog docs export --format txt --out /tmp/doc.txt` -- Docs cat: `gog docs cat ` - -Calendar Colors -- Use `gog calendar colors` to see all available event colors (IDs 1-11) -- Add colors to events with `--event-color ` flag -- Event color IDs (from `gog calendar colors` output): - - 1: #a4bdfc - - 2: #7ae7bf - - 3: #dbadff - - 4: #ff887c - - 5: #fbd75b - - 6: #ffb878 - - 7: #46d6db - - 8: #e1e1e1 - - 9: #5484ed - - 10: #51b749 - - 11: #dc2127 - -Email Formatting -- Prefer plain text. Use `--body-file` for multi-paragraph messages (or `--body-file -` for stdin). -- Same `--body-file` pattern works for drafts and replies. -- `--body` does not unescape `\n`. If you need inline newlines, use a heredoc or `$'Line 1\n\nLine 2'`. -- Use `--body-html` only when you need rich formatting. -- HTML tags: `

` for paragraphs, `
` for line breaks, `` for bold, `` for italic, `` for links, `