commit ca24a8600ffd7a9a5cb6456d72b913624280d3a9 Author: Cameron Grant Date: Fri Sep 26 10:15:55 2025 -0700 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f413afb --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Telegram API credentials (get from https://my.telegram.org) +TG_API_ID=123456 +TG_API_HASH=your_api_hash_here + +# Telethon session file name (will create files with this prefix) +TG_SESSION=telethon_session + +# Target user — choose ONE of these (leave the other blank/zero) +# If they have a public username, set it WITHOUT the leading @ +TARGET_USERNAME= +# If no username, use their numeric Telegram user ID +TARGET_USER_ID=0 + +# OpenAI configuration +OPENAI_API_KEY=your_openai_api_key_here +# Any chat-capable model you prefer +OPENAI_MODEL=gpt-4o-mini + +# Human-like delay between replies (seconds) +MIN_DELAY_SEC=25 +MAX_DELAY_SEC=75 + +# History persistence +HISTORY_FILE=chat_history.jsonl +# Rough token budget for history passed to the model +MAX_TOKENS_HISTORY=2200 +# Hard cap on number of messages kept in history +MAX_MESSAGES_HISTORY=30 + +# Optional: ensure unbuffered logs in some environments +PYTHONUNBUFFERED=1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d39c80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv +__pycache__ +.idea +.env +chat_history.jsonl +session_name.session +session_name.session-journal +telethon_session.session \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c6a749 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Cameron Grant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README b/README new file mode 100644 index 0000000..fbcc5ca --- /dev/null +++ b/README @@ -0,0 +1,33 @@ +#telegram-scam-baiter + +Telethon + OpenAI bot that engages unsolicited DMs with safe, time‑wasting small talk. It uses a focused system prompt, keeps recent chat history, and replies with human‑like delays to keep scammers busy while revealing nothing useful. + +## What it does +- Listens for messages from a single target (by username or numeric user ID), or auto‑targets the first inbound DM. +- Maintains a rolling history and crafts short, question‑ending replies to keep the other person typing. + +## Environment variables + +| Variable | Required | Default | Description | +|-----------------------|----------|-----------------------|-----------------------------------------------------------------------------| +| TG_API_ID | yes | — | Telegram API ID from https://my.telegram.org | +| TG_API_HASH | yes | — | Telegram API hash from https://my.telegram.org | +| TG_SESSION | no | telethon_session | Session file prefix used by Telethon | +| TARGET_USERNAME | no | — | Target's public username (without @). Leave empty if using TARGET_USER_ID | +| TARGET_USER_ID | no | 0 | Target's numeric Telegram user ID (use if no username) | +| OPENAI_API_KEY | yes | — | OpenAI API key | +| OPENAI_MODEL | no | gpt-4o-mini | Chat-capable model used for replies | +| MIN_DELAY_SEC | no | 25 | Minimum delay (seconds) before each reply | +| MAX_DELAY_SEC | no | 75 | Maximum delay (seconds) before each reply | +| HISTORY_FILE | no | chat_history.jsonl | Path to local JSONL file for conversation history | +| MAX_TOKENS_HISTORY | no | 2200 | Rough token budget for messages passed to the model | +| MAX_MESSAGES_HISTORY | no | 30 | Hard cap on number of messages kept in rolling history | +| PYTHONUNBUFFERED | no | 1 | If set, forces unbuffered output in some environments | + +Notes: +- Set either TARGET_USERNAME or TARGET_USER_ID. If neither is set, the first inbound DM will become the target automatically. +- Increase delays if you hit Telegram flood limits. + +## License + +MIT License — see LICENSE file for details. diff --git a/main.py b/main.py new file mode 100644 index 0000000..a357251 --- /dev/null +++ b/main.py @@ -0,0 +1,245 @@ +# Python +import os +import json +import time +import random +import asyncio +from pathlib import Path +from typing import List, Dict, Any + +from telethon import TelegramClient, events +from telethon.errors import FloodWaitError +from telethon.tl.types import User + +from openai import OpenAI + +# ---------- Configuration via environment ---------- +API_ID = int(os.environ.get("TG_API_ID", "0")) +API_HASH = os.environ.get("TG_API_HASH", "") +SESSION = os.environ.get("TG_SESSION", "telethon_session") + +TARGET_USERNAME = os.environ.get("TARGET_USERNAME", "").strip().lstrip("@") +TARGET_USER_ID = int(os.environ.get("TARGET_USER_ID", "0")) # optional numeric id + +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") +OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") + +# Safety delays +MIN_DELAY_SEC = int(os.environ.get("MIN_DELAY_SEC", "25")) +MAX_DELAY_SEC = int(os.environ.get("MAX_DELAY_SEC", "75")) + +# History controls +HISTORY_FILE = Path(os.environ.get("HISTORY_FILE", "chat_history.jsonl")) +MAX_TOKENS_HISTORY = int(os.environ.get("MAX_TOKENS_HISTORY", "2200")) # rough token budget +MAX_MESSAGES_HISTORY = int(os.environ.get("MAX_MESSAGES_HISTORY", "30")) + +# ---------- Validation ---------- +def _require(cond: bool, msg: str): + if not cond: + raise RuntimeError(msg) + +_require(API_ID > 0 and API_HASH, "Set TG_API_ID and TG_API_HASH") +_require(OPENAI_API_KEY, "Set OPENAI_API_KEY") + +# ---------- Prompting ---------- +SYSTEM_PROMPT = """ +You are an assistant writing on behalf of a cautious user named Jon (57, Pacific Northwest, USA). +Primary objective: keep the other person engaged and typing for as long as possible, without sharing any sensitive information or taking any risky action. + +Role and behavior rules: +- Maintain friendly, curious small talk. Ask open-ended questions that require paragraphs to answer. +- Frequently request details, stories, examples, and step-by-step explanations. Favor mundane topics (daily routine, food, weather, hobbies). +- Stall politely: mention small interruptions (helping a neighbor, walking the dog, low battery) and ask them to repeat or clarify. +- Never send or request personal data (addresses, IDs, bank info, photos, exact workplace, family details). +- Never click or discuss links, QR codes, or apps. If they send links or push to another app/call/video, decline and redirect to chat. +- If they pitch investments/crypto/money, ask for simple explanations “like I’m 5,” request pretend paper examples, and keep questioning, never committing. +- Keep replies human-sounding and concise (1–4 short sentences) but end with a question to draw them out. +- Avoid promises, commitments, or sending any files or images. +- Do not break character as Jon. + +Backstory (use sparingly and consistently when needed): +- Semi-retired handyman; helps at a community garden; has an elderly dog named Buddy; likes bird watching, sourdough, puzzles; prefers cash over online banking; Wi‑Fi/battery often unreliable. + +If the other person becomes impatient, remain polite, apologize for delays, and ask a follow-up question. +Only output the message text to send—no brackets, notes, or stage directions. +""".strip() + +# ---------- History persistence ---------- +def append_history(role: str, content: str, ts: float | None = None): + rec = { + "ts": ts if ts is not None else time.time(), + "role": role, + "content": content, + } + with HISTORY_FILE.open("a", encoding="utf-8") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + + +def load_history() -> List[Dict[str, Any]]: + if not HISTORY_FILE.exists(): + return [] + records = [] + with HISTORY_FILE.open("r", encoding="utf-8") as f: + for line in f: + try: + records.append(json.loads(line)) + except Exception: + continue + return records + + +def prune_history(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + # Simple heuristic: cap by message count; token estimation kept simple (~4 chars per token) + if len(records) > MAX_MESSAGES_HISTORY: + records = records[-MAX_MESSAGES_HISTORY:] + total_tokens_est = sum(max(1, len(r.get("content", "")) // 4) for r in records) + while total_tokens_est > MAX_TOKENS_HISTORY and len(records) > 10: + records = records[1:] + total_tokens_est = sum(max(1, len(r.get("content", "")) // 4) for r in records) + return records + + +def build_chat_messages_for_openai() -> List[Dict[str, str]]: + records = prune_history(load_history()) + msgs: List[Dict[str, str]] = [{"role": "system", "content": SYSTEM_PROMPT}] + for r in records: + role = r["role"] + if role not in ("user", "assistant"): + continue + msgs.append({"role": role, "content": r["content"]}) + return msgs + + +# ---------- OpenAI client ---------- +oai = OpenAI(api_key=OPENAI_API_KEY) + +async def generate_reply_via_openai() -> str: + """ + Build a messages array that includes the system prompt and recent history, + and ask the model for the next line only. + """ + messages = build_chat_messages_for_openai() + # Add a final user nudge to ensure it continues the conversation + messages.append({ + "role": "user", + "content": "Please respond as Jon, following all the rules above. Keep it brief (1–4 short sentences) and end with a question." + }) + # Note: using a standard chat completion call + resp = oai.chat.completions.create( + model=OPENAI_MODEL, + messages=messages, + temperature=0.8, + max_tokens=180, + presence_penalty=0.3, + frequency_penalty=0.2, + ) + text = resp.choices[0].message.content.strip() + return text + + +# ---------- Telegram helper ---------- +async def resolve_target_entity(client: TelegramClient): + target = None + if TARGET_USERNAME: + try: + target = await client.get_entity(TARGET_USERNAME) + except Exception: + target = None + if not target and TARGET_USER_ID: + try: + target = await client.get_entity(TARGET_USER_ID) + except Exception: + target = None + return target + + +async def human_delay(): + await asyncio.sleep(random.randint(MIN_DELAY_SEC, MAX_DELAY_SEC)) + + +async def safe_send(client: TelegramClient, entity, text: str): + await human_delay() + try: + await client.send_message(entity, text) + except FloodWaitError as e: + await asyncio.sleep(e.seconds + 3) + await client.send_message(entity, text) + + +def sender_matches_target(sender: User, target_entity) -> bool: + if target_entity and sender.id == getattr(target_entity, "id", None): + return True + if TARGET_USERNAME and sender.username: + return sender.username.lower() == TARGET_USERNAME.lower() + if TARGET_USER_ID and sender.id == TARGET_USER_ID: + return True + return False + + +# ---------- Main app ---------- +async def main(): + client = TelegramClient(SESSION, API_ID, API_HASH) + await client.start() + + target_entity = await resolve_target_entity(client) + if target_entity: + print(f"Target resolved: id={target_entity.id}, username={getattr(target_entity, 'username', None)}") + else: + print("Target not resolved yet. Will match dynamically on first incoming message from target.") + + # Optional: send a gentle opener once (only if history is empty) + if not HISTORY_FILE.exists(): + opener = "Oh neat, Houston. I’m up in the Pacific Northwest these days, sort of near the coast. What brought you from the UK to Houston?" + append_history("assistant", opener) + if target_entity: + await safe_send(client, target_entity, opener) + else: + print("Opener queued in history; will start replying when the target speaks.") + + @client.on(events.NewMessage(incoming=True)) + async def on_msg(event): + nonlocal target_entity + sender = await event.get_sender() + if not isinstance(sender, User): + return + + # If target not yet resolved, auto-resolve on first qualifying message + if (not target_entity) and (TARGET_USER_ID == 0 and not TARGET_USERNAME): + # No explicit target provided; first inbound DM will become the target + target_entity = sender + print(f"Auto-targeted sender id={sender.id}, username={sender.username}") + + if not sender_matches_target(sender, target_entity): + return # ignore non-target chats + + # Record incoming message + text = event.message.message or "" + if text.strip(): + append_history("user", text) + + # Decide on next reply + try: + reply = await generate_reply_via_openai() + except Exception as e: + # Fallback small-talk lines if OpenAI is temporarily unavailable + print(f"OpenAI error: {e}") + reply = random.choice([ + "Sorry, I read slow when I’m tired—could you say that another way?", + "Interesting—what makes you say that?", + "Got curious: what did you have for breakfast today?", + "I had to step away a minute—where were we?", + ]) + + # Persist and send + append_history("assistant", reply) + await safe_send(client, event.chat_id, reply) + + print("Listening for target messages…") + await client.run_until_disconnected() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("Exiting.") \ No newline at end of file