#!/usr/bin/env python3 # .----------------------------------------------------------. # | About / Copyright | # |----------------------------------------------------------| # | textnet - A Python-based Finger Client (RFC 1288) | # | | # | Copyright (c) 2025 https://640kb.neocities.org | # | | # | Licensed under the Blue Oak Model License 1.0.0 | # | https://blueoakcouncil.org/license/1.0.0 | # | | # | Run `textnet --license` to view the full license. | # | Run `textnet --helpdoc` for extended help. | # '----------------------------------------------------------' import platform import socket import sys import os import subprocess import re # .-----------------------------------------------------------------. # | Quick Start, Test: | # | | # | Linux: textnet @happynetbox.com | # | macOS: textnet @plan.cat | head -n 20 | # | Windows: python textnet.py @happynetbox.com | # | | # | On Windows, the 'python' prefix and .py extension are required. | # | --------------------------------------------------------------- | # | Note: textnet was written and fully tested on Linux. | # | | # | Except where noted in the Compatibility section of the | # | helpdoc, all other features should work on macOS and | # | Windows. | # '-----------------------------------------------------------------' # Rename SCRIPT_NAME to update all uses, including help text. SCRIPT_NAME = "textnet" VERSION = "1.1" VERSION_DATE = "Jul 8, 2025" # Set a fixed wrap width here. Example: 80 or 70. # This will be used with the "-w / --wrap" flag. # Set to _ None _ to use the terminal width. FIXED_WRAP_WIDTH = 80 # SCRIPT_EDITOR: Editor used for -e / --edit (default: xed) EDITOR = os.getenv('SCRIPT_EDITOR', 'xed') # SCRIPT_MAX_BYTES: Maximum bytes allowed per request (default: 100,000) max_bytes = int(os.getenv('SCRIPT_MAX_BYTES', '100000')) HELP_TEXT = f"""\ .----------------------------------------------------------------------------. | {SCRIPT_NAME.upper()} {VERSION} ({VERSION_DATE}) - A Python-based Finger Client (RFC 1288) | | | | {SCRIPT_NAME} is a Python-based finger client supporting additional text-only | | protocols. Features smart wrapping, HTML output and quick script editing. | | | | Usage: | | {SCRIPT_NAME} @domain.com # Query host | | {SCRIPT_NAME} user@domain.com # Query specific user | | {SCRIPT_NAME} @localhost # Fingerd must be installed locally | | | | Options: | | -v --version Show version info | | -h --help, -?, /? Show this help message | | -l --license Display full Blue Oak Model License 1.0.0| | -les --les Displays 'About Les Earnest' | | -------------------------------------------------------------------------- | | -e --edit Opens script in editor (may use sudo) | | -w --wrap Smart wrap, default: 80. Uses Linux fold | | -r --render Output HTML with clickable links | | -t --timeout Set timeout (default: 5 seconds) | | -------------------------------------------------------------------------- | | -q --qotd Query djxmmx.net, a QOTD server | | -d --define Define a word using WordNet at dict.org | | -s --synonym Query Moby Thesaurus at dict.org | | -whois --whois Perform a WHOIS lookup on a domain | | -------------------------------------------------------------------------- | | -helpdoc --helpdoc Help Documentation with Examples | | | | Configurable environment variables (edit these in script): | | SCRIPT_MAX_BYTES Maximum bytes allowed per request (default: 100,000) | | SCRIPT_EDITOR Editor used for -e / --edit (default: xed) | | FIXED_WRAP_WIDTH Set to any width (default: 80) | | SCRIPT_NAME Rename the script (default: "textnet") | '----------------------------------------------------------------------------' """ def print_helpdoc_and_exit(): print(f""" .--------------------------------------------------------------------------. | {SCRIPT_NAME} {VERSION} ({VERSION_DATE}) - A Python-based Finger Client (RFC 1288) | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Recommended viewing: | | | | {SCRIPT_NAME} -helpdoc | less | | or | | {SCRIPT_NAME} --helpdoc > filename.txt | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Description | |--------------------------------------------------------------------------| | | | {SCRIPT_NAME} is a Python-based finger client with support for additional | | text-only protocols. Currently supported: | | | | * finger:// | | * dict:// | | * Quote of the Day | | * whois | | | | Additional features include smart text wrapping, rendering finger output | | as preformatted HTML with clickable links, and quick script editing using| | the "-e" flag. | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Finger Usage | |--------------------------------------------------------------------------| | | | * {SCRIPT_NAME} @domain.com # Query host | | * {SCRIPT_NAME} user@domain.com # Query specific user | | * {SCRIPT_NAME} @localhost # fingerd must be installed locally | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Command Line Options | |--------------------------------------------------------------------------| | | | -v --version Show version info | | -h --help, -?, /? Show this help message | | -l --license Display full Blue Oak Model License 1.0.0 | | -les --les Display 'About Les Earnest' | | ------------------------------------------------------------------------ | | -e --edit Open script with default editor (uses sudo)| | -w --wrap Smart wrap (default: 80). Uses fold & less | | -r --render Output HTML with clickable links | | -t --timeout Set connection timeout (default: 5 seconds)| | ------------------------------------------------------------------------ | | -q --qotd Query djxmmx.net: Quote of the Day server | | -d --define Define a word using WordNet at dict.org | | -s --synonym Query Moby Thesaurus at dict.org | | -whois --whois Perform a WHOIS lookup on a domain | | ------------------------------------------------------------------------ | | -helpdoc --helpdoc Extended Help Documentation with Examples | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Configurable Environment Variables | |--------------------------------------------------------------------------| | | | SCRIPT_MAX_BYTES Maximum bytes allowed per request (default: 100,000) | | SCRIPT_EDITOR Editor used for -e / --edit (default: xed) | | FIXED_WRAP_WIDTH Set terminal wrap width (default: 80) | | SCRIPT_NAME Rename the script (default: "textnet") | | | | To change these defaults, simply edit the script and update the values. | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Configurable Hard-Coded Defaults | |--------------------------------------------------------------------------| | | | dict:// protocol: You can change the default host or dictionary used by | | the -d (--define) flag by editing the script directly. Look for: | | | | "def define_word(word, host='dict.org'" and follow the comment there. | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Installation | |--------------------------------------------------------------------------| | | | * install: | | chmod +x {SCRIPT_NAME} | | sudo install {SCRIPT_NAME} /usr/local/bin | | | | * uninstall: | | sudo rm /usr/local/bin/{SCRIPT_NAME} | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Compatibility | |--------------------------------------------------------------------------| | | | This script is primarily developed for Linux. | | | | All features are fully compatible with macOS. | | | | Nearly all features should work on Windows, though the script was not | | specifically written for that platform. Windows support is best-effort | | as I am unable to test on that system. | | | | For Windows users, there is currently only one known limitation: | | | | --wrap (relies on the Linux 'fold' command and is not supported) | | | | All other features are fully supported on Windows. | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Changelog | |--------------------------------------------------------------------------| | | | Version 1.1 (Jul 8, 2025) | | - Added synonym flags (-s and --synonym) | | | | Version 1.0 (Jul 7, 2025) | | - Initial public release | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Credits | |--------------------------------------------------------------------------| | | | textnet was created and is maintained by: | | https://640kb.neocities.org | | | | Copyright (c) 2025 https://640kb.neocities.org | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | License | |--------------------------------------------------------------------------| | | | textnet is licensed under the Blue Oak Model License 1.0.0 | | | | https://blueoakcouncil.org/license/1.0.0 | | | | Run `textnet --license` to view the full license. | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | T E X T N E T B Y E X A M P L E | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Finger Client: General Usage | |--------------------------------------------------------------------------| | | | {SCRIPT_NAME} @happynetbox.com | | {SCRIPT_NAME} user@happynetbox.com | | | | Basic finger usage, similar to most clients. | | ------------------------------------------------------------------------ | | {SCRIPT_NAME} fingerverse@happynetbox.com | less | | | | Useful for reading long .plan files, like the 'fingerverse'. | | ------------------------------------------------------------------------ | | {SCRIPT_NAME} -w username@plan.cat | | | | Uses the -w (--wrap) flag to ensure line breaks occur at word boundaries.| | The default width is 80 columns but can be changed via an environment | | variable. | | ------------------------------------------------------------------------ | | {SCRIPT_NAME} @plan.cat | head -n 20 | | | | Some servers return large user lists. The 'head -n 20' command limits | | the output to the first 20 lines. | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Smart Word Wrap | |--------------------------------------------------------------------------| | | | {SCRIPT_NAME} -w user@plan.cat | | {SCRIPT_NAME} --wrap user@happynetbox.com | | | | Some finger responses may exceed your terminal width, causing lines to | | wrap awkwardly and split words. | | | | The --wrap option enables smart word-wrapping and allows you to set the | | terminal width where wrapping should occur. | | | | The default width is 80 columns but can be changed by setting the | | FIXED_WRAP_WIDTH environment variable. | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Text to HTML Rendering | |--------------------------------------------------------------------------| | | | {SCRIPT_NAME} -r user@plan.cat > output.html | | {SCRIPT_NAME} --render user@plan.cat > output.html | | | | This option converts the response to a simple HTML file. It adds | |
 at the top, makes URLs clickable, and ends the file with     |
 | 
. | | | | The core purpose of this feature is to make links clickable. Text | | formatting is otherwise left unchanged. | | | | Supported link protocols: | | | | http:// https:// finger:// gemini:// spartan:// gopher:// | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Word Lookup via the Dictionary Protocol | |--------------------------------------------------------------------------| | | | {SCRIPT_NAME} -d | | {SCRIPT_NAME} --define | | | | Looks up the word using dict.org and the WordNet (wn) dictionary. | | | | Both the server (dict.org) and the dictionary (wn) can be changed by | | editing the script. Search for: | | | | def define_word(word, host='dict.org', timeout=5): | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Synonym Lookup via the Dictionary Protocol | |--------------------------------------------------------------------------| | | | {SCRIPT_NAME} -s | | {SCRIPT_NAME} --synonym | | | | Queries dict.org using the Moby Thesaurus for synonyms of the word. | | | | Both the server (dict.org) and the dictionary (moby-thesaurus) can be | | changed by editing the script. Search for: | | | | def synonym_lookup(word, host='dict.org', timeout=5): | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | whois: Domain and IP Lookup | |--------------------------------------------------------------------------| | | | {SCRIPT_NAME} -whois example.com | | {SCRIPT_NAME} --whois 192.0.2.1 | | | | Performs WHOIS lookups on domains and IP addresses. | | | | WHOIS remains one of the few widely used text-based network protocols. | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Instantly Edit This Script | |--------------------------------------------------------------------------| | | | {SCRIPT_NAME} -e | | {SCRIPT_NAME} --edit | | | | Use the -e or --edit flag alone; no additional parameters are needed. | | | | Opens this script in the default editor with sudo privileges (sudo for | | Linux & macOS only). This lets you quickly edit the script without having| | to locate it manually. | | | '--------------------------------------------------------------------------' .--------------------------------------------------------------------------. | Quote of the Day (server) | |--------------------------------------------------------------------------| | | | {SCRIPT_NAME} -q | | {SCRIPT_NAME} --quote | | | | Use the -q or --quote flag alone; no additional parameters are needed. | | | | Queries one of the last remaining 'Quote of the Day' (QOTD) servers | | (RFC 865) for a random quote. | | | '--------------------------------------------------------------------------' """) sys.exit(0) LICENSE_TEXT = """\ textnet - License Information Blue Oak Model License 1.0.0 https://blueoakcouncil.org/license/1.0.0 Copyright (c) 2025 https://640kb.neocities.org PURPOSE This license gives everyone as much permission to work with this software as possible, while protecting contributors from liability. ACCEPTANCE In order to receive this license, you must agree to its rules. The rules of this license are both obligations under that agreement and conditions to your license. You must not do anything with this software that triggers a rule that you cannot or will not follow. COPYRIGHT Each contributor licenses you to do everything with this software that would otherwise infringe that contributor's copyright in it. NOTICES You must ensure that everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license or a link to https://blueoakcouncil.org/license/1.0.0. EXCUSE If anyone notifies you in writing that you have not complied with the Notices section, you can keep your license by taking all practical steps to comply within 30 days after the notice. If you do not do so, your license ends immediately. PATENT Each contributor licenses you to do everything with this software that would otherwise infringe any patent claims they can license or become able to license. RELIABILITY No contributor can revoke this license. NO LIABILITY As far as the law allows, this software comes as is, without any warranty or condition, and no contributor will be liable to anyone for any damages related to this software or this license, under any kind of legal claim. Full license: https://blueoakcouncil.org/license/1.0.0 """ def finger_request(host, user='', timeout=5): port = 79 try: with socket.create_connection((host, port), timeout=timeout) as s: s.sendall((user + '\r\n').encode('utf-8')) response = b"" total_received = 0 while total_received < max_bytes: to_read = min(4096, max_bytes - total_received) data = s.recv(to_read) if not data: break response += data total_received += len(data) else: response += b"\n\033[93m[Warning: Maximum byte limit reached. Output may be truncated.]\033[0m\n" return response.decode('utf-8', errors='replace') except Exception as e: return f"\033[91mError: {e}\033[0m" def qotd_request(host='djxmmx.net', timeout=5): port = 17 try: with socket.create_connection((host, port), timeout=timeout) as s: quote = s.recv(1024).decode('utf-8', errors='replace') return quote.strip() except Exception as e: return f"\033[91mError: {e}\033[0m" # You can customize the dictionary lookup: # - Change host='dict.org' to use a different dictionary server. # - Change "wn" (WordNet) to another dictionary name; use "*" to search all books def define_word(word, host='dict.org', timeout=5): port = 2628 try: with socket.create_connection((host, port), timeout=timeout) as s: s.sendall(f"DEFINE wn {word}\r\n".encode('utf-8')) response = b"" while True: data = s.recv(4096) if not data: break response += data if b"\r\n.\r\n" in response: break lines = response.decode('utf-8', errors='replace').splitlines() # Remove first four lines (header) and last two lines (footer) if len(lines) > 6: cleaned_lines = lines[4:-2] else: cleaned_lines = lines # Fallback in case something unexpected happens intro = f'\nSearching for "{word}" on {host} using the wn (WordNet) dictionary.\n' return intro + '\n' + '\n'.join(cleaned_lines) + '\n' except Exception as e: return f"\033[91mError: {e}\033[0m" # You can customize the synonym lookup: # - Change host='dict.org' to use a different dictionary server. # - Change "moby-thesaurus" to another synonym dictionary def synonym_lookup(word, host='dict.org', timeout=5): port = 2628 try: with socket.create_connection((host, port), timeout=timeout) as s: s.sendall(f"DEFINE moby-thesaurus {word}\r\n".encode('utf-8')) response = b"" while True: data = s.recv(4096) if not data: break response += data if b"\r\n.\r\n" in response: break lines = response.decode('utf-8', errors='replace').splitlines() if len(lines) > 6: cleaned_lines = lines[4:-2] else: cleaned_lines = lines intro = f'\nSearching for synonyms of "{word}" on {host} using the moby-thesaurus.\n' return intro + '\n' + '\n'.join(cleaned_lines) + '\n' except Exception as e: return f"\033[91mError: {e}\033[0m" # Simple WHOIS lookup # - Change host='whois.iana.org' to use a different WHOIS server if desired. def whois_request(domain, host='whois.iana.org', timeout=5): port = 43 try: with socket.create_connection((host, port), timeout=timeout) as s: s.sendall((domain + '\r\n').encode('utf-8')) response = b"" while True: data = s.recv(4096) if not data: break response += data return f'\nPerforming WHOIS lookup on {domain} using {host}\n\n' + response.decode('utf-8', errors='replace') + '\n' except Exception as e: return f"\033[91mError: {e}\033[0m" def parse_finger_target(target): if '@' not in target: print("\033[91mError: Invalid format. Use username@host or @host.\033[0m") sys.exit(1) if target.startswith('@'): user = '' host = target[1:] else: user, host = target.split('@', 1) return host, user def print_help_and_exit(): print(HELP_TEXT) sys.exit(0) def print_version_and_exit(): print(f"{SCRIPT_NAME} {VERSION} ({VERSION_DATE})") sys.exit(0) def print_les_about_and_exit(): print(f""" Les Earnest (1930 - 2024) ------------------------- Les Earnest was a computer scientist best known for his work on the ARPANET - the precursor to today's modern Internet. He made significant contributions to robotics, speech recognition, and cursive handwriting recognition. He later founded Imagen Corp. Earnest also invented the finger program and protocol, the first network-wide service for determining user presence - a feature now common in most chat and messaging applications. The name "finger" came from Earnest's observation of people scanning the list of logged-in users on UNIX systems by running their finger down the screen. To explore the finger protocol today, visit the fingerverse: Linux/Mac: {SCRIPT_NAME} fingerverse@happynetbox.com | less Windows: python {SCRIPT_NAME}.py fingerverse@happynetbox.com | more Or browse: https://640kb.neocities.org/fingerverse """) sys.exit(0) def get_terminal_width(): try: return int(os.popen('tput cols').read().strip()) except Exception: return 80 # ----------------------------------- def wrap_and_page(text, width): try: # Run fold and capture its output fold = subprocess.Popen(['fold', '-s', f'-w{width}'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) folded_output, _ = fold.communicate(input=text.encode('utf-8')) # Now page the folded output through less less = subprocess.Popen(['less'], stdin=subprocess.PIPE) less.communicate(input=folded_output) except KeyboardInterrupt: pass def render_links(text): url_pattern = re.compile( r'((?:https?|gemini|finger|spartan|gopher)://[^\s<>()\[\]{}]+[A-Za-z0-9/])' ) rendered_text = url_pattern.sub(r'\1', text) return f"
\n{rendered_text}\n
" def main(): args = sys.argv[1:] if not args: print_help_and_exit() help_flags = ['-h', '--help', '-?', '/?'] if any(arg in help_flags for arg in args): print_help_and_exit() if '-helpdoc' in args or '--helpdoc' in args: print_helpdoc_and_exit() version_flags = ['-v', '--version'] if any(arg in version_flags for arg in args): print_version_and_exit() if '-les' in args or '--les' in args: print_les_about_and_exit() if '-e' in args or '--edit' in args: script_path = os.path.realpath(__file__) try: if platform.system() == "Windows": subprocess.run([EDITOR, script_path]) else: subprocess.run(['sudo', EDITOR, script_path]) except Exception as e: print(f"\033[91mError: Failed to open editor: {e}\033[0m") sys.exit(0) if '-q' in args or '--qotd' in args: quote = qotd_request() print(quote) sys.exit(0) if '-l' in args or '--license' in args: print(LICENSE_TEXT) sys.exit(0) if '-d' in args: idx = args.index('-d') if idx + 1 < len(args): word = args[idx + 1] else: print("\033[91mError: A word must follow the -d flag.\033[0m") sys.exit(1) definition = define_word(word) print(definition) sys.exit(0) elif '--define' in args: idx = args.index('--define') if idx + 1 < len(args): word = args[idx + 1] else: print("\033[91mError: A word must follow the --define flag.\033[0m") sys.exit(1) definition = define_word(word) print(definition) sys.exit(0) if '-s' in args: idx = args.index('-s') if idx + 1 < len(args): word = args[idx + 1] else: print("\033[91mError: A word must follow the -s flag.\033[0m") sys.exit(1) synonyms = synonym_lookup(word) print(synonyms) sys.exit(0) elif '--synonym' in args: idx = args.index('--synonym') if idx + 1 < len(args): word = args[idx + 1] else: print("\033[91mError: A word must follow the --synonym flag.\033[0m") sys.exit(1) synonyms = synonym_lookup(word) print(synonyms) sys.exit(0) if '-whois' in args or '--whois' in args: if '-whois' in args: idx = args.index('-whois') else: idx = args.index('--whois') if idx + 1 < len(args): domain = args[idx + 1] else: print("\033[91mError: A domain must follow the -whois flag.\033[0m") sys.exit(1) whois_result = whois_request(domain) print(whois_result) sys.exit(0) wrap_output = '-w' in args or '--wrap' in args render_output = '-r' in args or '--render' in args timeout = 5 if '-t' in args: idx = args.index('-t') if idx + 1 < len(args): try: timeout = int(args[idx + 1]) except ValueError: print("\033[91mError: Timeout must be an integer.\033[0m") sys.exit(1) else: print("\033[91mError: Timeout value expected after -t.\033[0m") sys.exit(1) elif '--timeout' in args: idx = args.index('--timeout') if idx + 1 < len(args): try: timeout = int(args[idx + 1]) except ValueError: print("\033[91mError: Timeout must be an integer.\033[0m") sys.exit(1) else: print("\033[91mError: Timeout value expected after --timeout.\033[0m") sys.exit(1) positional_args = [arg for arg in args if not arg.startswith('-')] if len(positional_args) != 1: print("\033[91mError: Exactly one finger target argument is required (username@host or @host).\033[0m") sys.exit(1) target = positional_args[0] host, user = parse_finger_target(target) result = finger_request(host, user, timeout) try: if render_output: result = render_links(result) if wrap_output and not render_output: width = FIXED_WRAP_WIDTH if FIXED_WRAP_WIDTH is not None else get_terminal_width() wrap_and_page(result, width) else: print(result) except BrokenPipeError: try: sys.stdout.close() except Exception: pass sys.exit(0) if __name__ == "__main__": main()