Path: csiph.com!weretis.net!feeder9.news.weretis.net!border-1.nntp.ord.giganews.com!nntp.giganews.com!news-out.netnews.com!postmaster.netnews.com!dca.netnews.com!not-for-mail X-Trace: DXC=CcZH71hIg;D@ILHAO\[GICHWonT5<]0TM@GOK[m5A6WB3[L8B6BE9jM^YDET]56OLAdMQ7X:[aNBMDmCB8nK;\gJ`S;^F3iZe NNTP-Posting-Host: 127.0.0.1 X-Trace: 1766177673 reader.netnews.com 24 127.0.0.1:36517 Xref: csiph.com comp.lang.python:197641 AI Generated Usenet Client in Python If you have any improvements to this or helpful ideas I would like to see the= m. https://github.com/alt-magick/Newsgroup-Client-/ #!/usr/bin/env python3 import nntplib import sys import termios import tty import re import quopri import base64 from email.message import EmailMessage import os import tempfile import subprocess # =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D USER CONFIG =3D=3D=3D= =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D NNTP_SERVER =3D "usnews.blocknews.net" NNTP_PORT =3D 563 USERNAME =3D "" PASSWORD =3D "" PAGE_LINES =3D 12 MAX_ARTICLES_LIST =3D 200 MAX_REPLY_SCAN =3D 300 START_GROUP =3D "alt.test" SHOW_REPLY_COUNT =3D False SHOW_REPLY_COUNT_MAIN =3D True # =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D= =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D RE_REPLY =3D re.compile(r"^(re|fwd):", re.IGNORECASE) CLEAN_RE =3D re.compile(r"[=00-=08=0B-=0C=0E-=1F]") # ---------- STATUS LINE ---------- STATUS_LINE =3D "" def set_status(msg): global STATUS_LINE STATUS_LINE =3D msg def show_status(): global STATUS_LINE if STATUS_LINE: print(f" [{STATUS_LINE}]") STATUS_LINE =3D "" # ---------- RAW KEY INPUT ---------- def get_key(): fd =3D sys.stdin.fileno() old =3D termios.tcgetattr(fd) try: tty.setraw(fd) return sys.stdin.read(1) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old) def prompt(text): sys.stdout.write(text) sys.stdout.flush() return sys.stdin.readline().strip() # ---------- HARD-CODED PAGER ---------- def paged_print(lines): i =3D 0 total =3D len(lines) while i < total: end =3D min(i + PAGE_LINES, total) for line in lines[i:end]: print(line) i =3D end if i >=3D total: break print(" --- ENTER =3D next page | SPACE =3D skip ---") if get_key() =3D=3D " ": break # ---------- BODY DECODER ---------- def decode_body_line(line_bytes): s =3D line_bytes.decode("utf-8", errors=3D"replace") s =3D CLEAN_RE.sub("", s) if "=3D" in s: try: s =3D quopri.decodestring(s).decode("utf-8", errors=3D"replace") except Exception: pass if re.fullmatch(r"[A-Za-z0-9+/=3D\s]+", s) and len(s.strip()) > 20: try: s =3D base64.b64decode(s, validate=3DTrue).decode("utf-8", errors= =3D"replace") except Exception: pass return s # ---------- POST BODY EDITOR ---------- def edit_body(initial=3D""): editor =3D os.environ.get("EDITOR", "nano") fd, path =3D tempfile.mkstemp(suffix=3D".txt") try: with os.fdopen(fd, "w") as f: f.write(initial) subprocess.call([editor, path]) with open(path, "r") as f: return f.read() finally: os.unlink(path) # ---------- POST BODY SOURCE ---------- def get_post_body(): print(" Post body source:") print(" E =3D Edit in editor") print(" F =3D Load from external text file") print(" T =3D Type directly in terminal") choice =3D get_key().lower() if choice =3D=3D "f": path =3D prompt(" Enter path to text file: ") try: with open(path, "r", encoding=3D"utf-8", errors=3D"replace") as f: body =3D f.read() if not body.strip(): set_status("Post aborted (file is empty)") return None return body except Exception as e: set_status(f"Failed to read file: {e}") return None elif choice =3D=3D "t": print(" Type your post below. End with a period on a line by itself.") lines =3D [] while True: line =3D input() if line.strip() =3D=3D ".": break # stop input when user enters a single period on a line lines.append(line) body =3D " ".join(lines) if not body.strip(): set_status("Post aborted (empty input)") return None return body # default: editor body =3D edit_body() if not body.strip(): set_status("Post aborted (empty body)") return None return body # ---------- POSTING ---------- def post_article(nntp, group, subject=3DNone, references=3DNone): name =3D prompt("Enter your display name: ") email =3D prompt("Enter your email: ") if not subject: subject =3D prompt("Enter subject: ") body =3D get_post_body() if not body: return False msg =3D EmailMessage() msg["From"] =3D f"{name} <{email}>" msg["Newsgroups"] =3D group msg["Subject"] =3D subject if references: msg["References"] =3D references msg.set_content(body) try: nntp.post(msg.as_bytes()) set_status("Article posted successfully") return True except Exception as e: set_status(f"Post failed: {e}") return False # ---------- REPLY POSTING ---------- def post_reply(nntp, group, article_num): try: _, hinfo =3D nntp.head(str(article_num)) headers =3D {} for raw in hinfo.lines: line =3D decode_body_line(raw) if ":" in line: k, v =3D line.split(":", 1) headers[k.lower()] =3D v.strip() subject =3D headers.get("subject", "(no subject)") if not RE_REPLY.match(subject): subject =3D "Re: " + subject refs =3D [] if "references" in headers: refs.append(headers["references"]) if "message-id" in headers: refs.append(headers["message-id"]) return post_article(nntp, group, subject, " ".join(refs)) except Exception as e: set_status(f"Reply failed: {e}") return False # ---------- ARTICLE DISPLAY ---------- def show_article(nntp, num, group=3DNone, allow_reply=3DFalse): try: _, hinfo =3D nntp.head(str(num)) headers =3D {} for raw in hinfo.lines: line =3D decode_body_line(raw) if ":" in line: k, v =3D line.split(":", 1) headers[k.lower()] =3D v.strip() _, body =3D nntp.body(str(num)) lines =3D [decode_body_line(l) for l in body.lines] paged_print([ f"From: {headers.get('from','?')}", f"Date: {headers.get('date','?')}", f"Subject: {headers.get('subject','(no subject)')}", "" ] + lines) if allow_reply and group: print(" P=3Dreply (any other key to continue)") if get_key().lower() =3D=3D "p": post_reply(nntp, group, num) except Exception as e: set_status(f"Fetch failed: {e}") # ---------- REPLY SCANNING ---------- def scan_replies_xover(nntp, msgid, first, last): replies =3D [] start =3D max(first, last - MAX_REPLY_SCAN) try: _, overviews =3D nntp.over((start, last)) except: return replies for num, hdr in overviews: if msgid in hdr.get("references", ""): replies.append(int(num)) return replies # ---------- GROUP RELOAD ---------- def reload_group(nntp, group): try: _, _, first, last, _ =3D nntp.group(group) first =3D int(first) last =3D int(last) _, overviews =3D nntp.over((max(first, last - MAX_ARTICLES_LIST), las= t)) posts =3D [] for num, hdr in reversed(overviews): subject =3D hdr.get("subject", "") if RE_REPLY.match(subject): continue msgid =3D hdr.get("message-id", "") replies =3D sum( 1 for _, h in overviews if msgid in h.get("references", "") ) if SHOW_REPLY_COUNT_MAIN else 0 posts.append({ "num": int(num), "subject": CLEAN_RE.sub("", subject), "from": CLEAN_RE.sub("", hdr.get("from", "?")), "date": hdr.get("date", "?"), "msgid": msgid, "replies": replies }) return posts, first, last except Exception as e: set_status(f"Reload failed: {e}") return None, None, None # ---------- GROUP BROWSER ---------- def browse_group(nntp, group): posts, first, last =3D reload_group(nntp, group) if not posts: return index =3D 0 while index < len(posts): p =3D posts[index] print(f" [{index+1}] #{p['num']}") print(f"From: {p['from']}") print(f"Date: {p['date']}") print(f"Replies: {p['replies'] if SHOW_REPLY_COUNT_MAIN else '?'}") print(f"Subject: {p['subject']}") show_status() print(" ENTER=3Dread SPACE=3Dnext R=3Dreplies N=3Dnew post L=3Dreload J=3Djump = G=3Dgroup Q=3Dquit") key =3D get_key().lower() if key =3D=3D "q": sys.exit(0) elif key =3D=3D " ": index +=3D 1 elif key in (" ", " "): show_article(nntp, p["num"], group, True) elif key =3D=3D "n": if post_article(nntp, group): posts, first, last =3D reload_group(nntp, group) index =3D 0 elif key =3D=3D "l": posts, first, last =3D reload_group(nntp, group) index =3D 0 set_status("Group reloaded") elif key =3D=3D "j": val =3D prompt("Jump to post number: ") if val.isdigit(): idx =3D int(val) - 1 if 0 <=3D idx < len(posts): index =3D idx elif key =3D=3D "g": browse_group(nntp, prompt("New group: ")) return elif key =3D=3D "r": replies =3D scan_replies_xover(nntp, p["msgid"], first, last) if not replies: set_status("No replies found") continue for i, rnum in enumerate(replies): if i < len(replies) - 1: print(" ENTER=3Dnext reply | SPACE=3Dskip remaining | P=3Dreply") else: print(" End of replies | P=3Dreply") k =3D get_key().lower() if k =3D=3D " ": set_status("Skipped remaining replies") break elif k =3D=3D "p": post_reply(nntp, group, rnum) elif k in (" ", " "): show_article(nntp, rnum, group, True) # ---------- MAIN ---------- def main(): print(f"Connecting to {NNTP_SERVER}:{NNTP_PORT}...") nntp =3D nntplib.NNTP_SSL(NNTP_SERVER, NNTP_PORT, USERNAME, PASSWORD) set_status("Connected") browse_group(nntp, START_GROUP) nntp.quit() if __name__ =3D=3D "__main__": main()