A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: multi-account support, interactive CLI, and custom PDS support

jack 4d3c6135 0dda1818

+1224 -507
+2
.gitignore
··· 2 2 dist/ 3 3 .env 4 4 processed_tweets.json 5 + config.json 6 + processed/ 5 7 npm-debug.log 6 8 .DS_Store
+547 -2
package-lock.json
··· 12 12 "@atproto/api": "^0.18.9", 13 13 "@steipete/bird": "^0.4.0", 14 14 "axios": "^1.13.2", 15 + "commander": "^14.0.2", 15 16 "dotenv": "^17.2.3", 16 17 "franc-min": "^6.2.0", 18 + "inquirer": "^13.1.0", 17 19 "iso-639-1": "^3.1.2", 18 20 "node-cron": "^4.2.1" 19 21 }, 20 22 "devDependencies": { 21 23 "@biomejs/biome": "^1.9.4", 24 + "@types/inquirer": "^9.0.9", 22 25 "@types/node": "^22.10.2", 23 26 "tsx": "^4.19.2", 24 27 "typescript": "^5.7.2" ··· 709 712 "node": ">=18" 710 713 } 711 714 }, 715 + "node_modules/@inquirer/ansi": { 716 + "version": "2.0.2", 717 + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.2.tgz", 718 + "integrity": "sha512-SYLX05PwJVnW+WVegZt1T4Ip1qba1ik+pNJPDiqvk6zS5Y/i8PhRzLpGEtVd7sW0G8cMtkD8t4AZYhQwm8vnww==", 719 + "license": "MIT", 720 + "engines": { 721 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 722 + } 723 + }, 724 + "node_modules/@inquirer/checkbox": { 725 + "version": "5.0.3", 726 + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.0.3.tgz", 727 + "integrity": "sha512-xtQP2eXMFlOcAhZ4ReKP2KZvDIBb1AnCfZ81wWXG3DXLVH0f0g4obE0XDPH+ukAEMRcZT0kdX2AS1jrWGXbpxw==", 728 + "license": "MIT", 729 + "dependencies": { 730 + "@inquirer/ansi": "^2.0.2", 731 + "@inquirer/core": "^11.1.0", 732 + "@inquirer/figures": "^2.0.2", 733 + "@inquirer/type": "^4.0.2" 734 + }, 735 + "engines": { 736 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 737 + }, 738 + "peerDependencies": { 739 + "@types/node": ">=18" 740 + }, 741 + "peerDependenciesMeta": { 742 + "@types/node": { 743 + "optional": true 744 + } 745 + } 746 + }, 747 + "node_modules/@inquirer/confirm": { 748 + "version": "6.0.3", 749 + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.3.tgz", 750 + "integrity": "sha512-lyEvibDFL+NA5R4xl8FUmNhmu81B+LDL9L/MpKkZlQDJZXzG8InxiqYxiAlQYa9cqLLhYqKLQwZqXmSTqCLjyw==", 751 + "license": "MIT", 752 + "dependencies": { 753 + "@inquirer/core": "^11.1.0", 754 + "@inquirer/type": "^4.0.2" 755 + }, 756 + "engines": { 757 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 758 + }, 759 + "peerDependencies": { 760 + "@types/node": ">=18" 761 + }, 762 + "peerDependenciesMeta": { 763 + "@types/node": { 764 + "optional": true 765 + } 766 + } 767 + }, 768 + "node_modules/@inquirer/core": { 769 + "version": "11.1.0", 770 + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.0.tgz", 771 + "integrity": "sha512-+jD/34T1pK8M5QmZD/ENhOfXdl9Zr+BrQAUc5h2anWgi7gggRq15ZbiBeLoObj0TLbdgW7TAIQRU2boMc9uOKQ==", 772 + "license": "MIT", 773 + "dependencies": { 774 + "@inquirer/ansi": "^2.0.2", 775 + "@inquirer/figures": "^2.0.2", 776 + "@inquirer/type": "^4.0.2", 777 + "cli-width": "^4.1.0", 778 + "mute-stream": "^3.0.0", 779 + "signal-exit": "^4.1.0", 780 + "wrap-ansi": "^9.0.2" 781 + }, 782 + "engines": { 783 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 784 + }, 785 + "peerDependencies": { 786 + "@types/node": ">=18" 787 + }, 788 + "peerDependenciesMeta": { 789 + "@types/node": { 790 + "optional": true 791 + } 792 + } 793 + }, 794 + "node_modules/@inquirer/editor": { 795 + "version": "5.0.3", 796 + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.3.tgz", 797 + "integrity": "sha512-wYyQo96TsAqIciP/r5D3cFeV8h4WqKQ/YOvTg5yOfP2sqEbVVpbxPpfV3LM5D0EP4zUI3EZVHyIUIllnoIa8OQ==", 798 + "license": "MIT", 799 + "dependencies": { 800 + "@inquirer/core": "^11.1.0", 801 + "@inquirer/external-editor": "^2.0.2", 802 + "@inquirer/type": "^4.0.2" 803 + }, 804 + "engines": { 805 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 806 + }, 807 + "peerDependencies": { 808 + "@types/node": ">=18" 809 + }, 810 + "peerDependenciesMeta": { 811 + "@types/node": { 812 + "optional": true 813 + } 814 + } 815 + }, 816 + "node_modules/@inquirer/expand": { 817 + "version": "5.0.3", 818 + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.3.tgz", 819 + "integrity": "sha512-2oINvuL27ujjxd95f6K2K909uZOU2x1WiAl7Wb1X/xOtL8CgQ1kSxzykIr7u4xTkXkXOAkCuF45T588/YKee7w==", 820 + "license": "MIT", 821 + "dependencies": { 822 + "@inquirer/core": "^11.1.0", 823 + "@inquirer/type": "^4.0.2" 824 + }, 825 + "engines": { 826 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 827 + }, 828 + "peerDependencies": { 829 + "@types/node": ">=18" 830 + }, 831 + "peerDependenciesMeta": { 832 + "@types/node": { 833 + "optional": true 834 + } 835 + } 836 + }, 837 + "node_modules/@inquirer/external-editor": { 838 + "version": "2.0.2", 839 + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.2.tgz", 840 + "integrity": "sha512-X/fMXK7vXomRWEex1j8mnj7s1mpnTeP4CO/h2gysJhHLT2WjBnLv4ZQEGpm/kcYI8QfLZ2fgW+9kTKD+jeopLg==", 841 + "license": "MIT", 842 + "dependencies": { 843 + "chardet": "^2.1.1", 844 + "iconv-lite": "^0.7.0" 845 + }, 846 + "engines": { 847 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 848 + }, 849 + "peerDependencies": { 850 + "@types/node": ">=18" 851 + }, 852 + "peerDependenciesMeta": { 853 + "@types/node": { 854 + "optional": true 855 + } 856 + } 857 + }, 858 + "node_modules/@inquirer/figures": { 859 + "version": "2.0.2", 860 + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.2.tgz", 861 + "integrity": "sha512-qXm6EVvQx/FmnSrCWCIGtMHwqeLgxABP8XgcaAoywsL0NFga9gD5kfG0gXiv80GjK9Hsoz4pgGwF/+CjygyV9A==", 862 + "license": "MIT", 863 + "engines": { 864 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 865 + } 866 + }, 867 + "node_modules/@inquirer/input": { 868 + "version": "5.0.3", 869 + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.3.tgz", 870 + "integrity": "sha512-4R0TdWl53dtp79Vs6Df2OHAtA2FVNqya1hND1f5wjHWxZJxwDMSNB1X5ADZJSsQKYAJ5JHCTO+GpJZ42mK0Otw==", 871 + "license": "MIT", 872 + "dependencies": { 873 + "@inquirer/core": "^11.1.0", 874 + "@inquirer/type": "^4.0.2" 875 + }, 876 + "engines": { 877 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 878 + }, 879 + "peerDependencies": { 880 + "@types/node": ">=18" 881 + }, 882 + "peerDependenciesMeta": { 883 + "@types/node": { 884 + "optional": true 885 + } 886 + } 887 + }, 888 + "node_modules/@inquirer/number": { 889 + "version": "4.0.3", 890 + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.3.tgz", 891 + "integrity": "sha512-TjQLe93GGo5snRlu83JxE38ZPqj5ZVggL+QqqAF2oBA5JOJoxx25GG3EGH/XN/Os5WOmKfO8iLVdCXQxXRZIMQ==", 892 + "license": "MIT", 893 + "dependencies": { 894 + "@inquirer/core": "^11.1.0", 895 + "@inquirer/type": "^4.0.2" 896 + }, 897 + "engines": { 898 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 899 + }, 900 + "peerDependencies": { 901 + "@types/node": ">=18" 902 + }, 903 + "peerDependenciesMeta": { 904 + "@types/node": { 905 + "optional": true 906 + } 907 + } 908 + }, 909 + "node_modules/@inquirer/password": { 910 + "version": "5.0.3", 911 + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.3.tgz", 912 + "integrity": "sha512-rCozGbUMAHedTeYWEN8sgZH4lRCdgG/WinFkit6ZPsp8JaNg2T0g3QslPBS5XbpORyKP/I+xyBO81kFEvhBmjA==", 913 + "license": "MIT", 914 + "dependencies": { 915 + "@inquirer/ansi": "^2.0.2", 916 + "@inquirer/core": "^11.1.0", 917 + "@inquirer/type": "^4.0.2" 918 + }, 919 + "engines": { 920 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 921 + }, 922 + "peerDependencies": { 923 + "@types/node": ">=18" 924 + }, 925 + "peerDependenciesMeta": { 926 + "@types/node": { 927 + "optional": true 928 + } 929 + } 930 + }, 931 + "node_modules/@inquirer/prompts": { 932 + "version": "8.1.0", 933 + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.1.0.tgz", 934 + "integrity": "sha512-LsZMdKcmRNF5LyTRuZE5nWeOjganzmN3zwbtNfcs6GPh3I2TsTtF1UYZlbxVfhxd+EuUqLGs/Lm3Xt4v6Az1wA==", 935 + "license": "MIT", 936 + "dependencies": { 937 + "@inquirer/checkbox": "^5.0.3", 938 + "@inquirer/confirm": "^6.0.3", 939 + "@inquirer/editor": "^5.0.3", 940 + "@inquirer/expand": "^5.0.3", 941 + "@inquirer/input": "^5.0.3", 942 + "@inquirer/number": "^4.0.3", 943 + "@inquirer/password": "^5.0.3", 944 + "@inquirer/rawlist": "^5.1.0", 945 + "@inquirer/search": "^4.0.3", 946 + "@inquirer/select": "^5.0.3" 947 + }, 948 + "engines": { 949 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 950 + }, 951 + "peerDependencies": { 952 + "@types/node": ">=18" 953 + }, 954 + "peerDependenciesMeta": { 955 + "@types/node": { 956 + "optional": true 957 + } 958 + } 959 + }, 960 + "node_modules/@inquirer/rawlist": { 961 + "version": "5.1.0", 962 + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.1.0.tgz", 963 + "integrity": "sha512-yUCuVh0jW026Gr2tZlG3kHignxcrLKDR3KBp+eUgNz+BAdSeZk0e18yt2gyBr+giYhj/WSIHCmPDOgp1mT2niQ==", 964 + "license": "MIT", 965 + "dependencies": { 966 + "@inquirer/core": "^11.1.0", 967 + "@inquirer/type": "^4.0.2" 968 + }, 969 + "engines": { 970 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 971 + }, 972 + "peerDependencies": { 973 + "@types/node": ">=18" 974 + }, 975 + "peerDependenciesMeta": { 976 + "@types/node": { 977 + "optional": true 978 + } 979 + } 980 + }, 981 + "node_modules/@inquirer/search": { 982 + "version": "4.0.3", 983 + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.0.3.tgz", 984 + "integrity": "sha512-lzqVw0YwuKYetk5VwJ81Ba+dyVlhseHPx9YnRKQgwXdFS0kEavCz2gngnNhnMIxg8+j1N/rUl1t5s1npwa7bqg==", 985 + "license": "MIT", 986 + "dependencies": { 987 + "@inquirer/core": "^11.1.0", 988 + "@inquirer/figures": "^2.0.2", 989 + "@inquirer/type": "^4.0.2" 990 + }, 991 + "engines": { 992 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 993 + }, 994 + "peerDependencies": { 995 + "@types/node": ">=18" 996 + }, 997 + "peerDependenciesMeta": { 998 + "@types/node": { 999 + "optional": true 1000 + } 1001 + } 1002 + }, 1003 + "node_modules/@inquirer/select": { 1004 + "version": "5.0.3", 1005 + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.0.3.tgz", 1006 + "integrity": "sha512-M+ynbwS0ecQFDYMFrQrybA0qL8DV0snpc4kKevCCNaTpfghsRowRY7SlQBeIYNzHqXtiiz4RG9vTOeb/udew7w==", 1007 + "license": "MIT", 1008 + "dependencies": { 1009 + "@inquirer/ansi": "^2.0.2", 1010 + "@inquirer/core": "^11.1.0", 1011 + "@inquirer/figures": "^2.0.2", 1012 + "@inquirer/type": "^4.0.2" 1013 + }, 1014 + "engines": { 1015 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 1016 + }, 1017 + "peerDependencies": { 1018 + "@types/node": ">=18" 1019 + }, 1020 + "peerDependenciesMeta": { 1021 + "@types/node": { 1022 + "optional": true 1023 + } 1024 + } 1025 + }, 1026 + "node_modules/@inquirer/type": { 1027 + "version": "4.0.2", 1028 + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.2.tgz", 1029 + "integrity": "sha512-cae7mzluplsjSdgFA6ACLygb5jC8alO0UUnFPyu0E7tNRPrL+q/f8VcSXp+cjZQ7l5CMpDpi2G1+IQvkOiL1Lw==", 1030 + "license": "MIT", 1031 + "engines": { 1032 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 1033 + }, 1034 + "peerDependencies": { 1035 + "@types/node": ">=18" 1036 + }, 1037 + "peerDependenciesMeta": { 1038 + "@types/node": { 1039 + "optional": true 1040 + } 1041 + } 1042 + }, 712 1043 "node_modules/@steipete/bird": { 713 1044 "version": "0.4.0", 714 1045 "resolved": "https://registry.npmjs.org/@steipete/bird/-/bird-0.4.0.tgz", ··· 725 1056 "node": ">=20" 726 1057 } 727 1058 }, 1059 + "node_modules/@types/inquirer": { 1060 + "version": "9.0.9", 1061 + "resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-9.0.9.tgz", 1062 + "integrity": "sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw==", 1063 + "dev": true, 1064 + "license": "MIT", 1065 + "dependencies": { 1066 + "@types/through": "*", 1067 + "rxjs": "^7.2.0" 1068 + } 1069 + }, 728 1070 "node_modules/@types/node": { 729 1071 "version": "22.19.3", 730 1072 "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", 731 1073 "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", 1074 + "devOptional": true, 1075 + "license": "MIT", 1076 + "dependencies": { 1077 + "undici-types": "~6.21.0" 1078 + } 1079 + }, 1080 + "node_modules/@types/through": { 1081 + "version": "0.0.33", 1082 + "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", 1083 + "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", 732 1084 "dev": true, 733 1085 "license": "MIT", 734 1086 "dependencies": { 735 - "undici-types": "~6.21.0" 1087 + "@types/node": "*" 1088 + } 1089 + }, 1090 + "node_modules/ansi-regex": { 1091 + "version": "6.2.2", 1092 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", 1093 + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", 1094 + "license": "MIT", 1095 + "engines": { 1096 + "node": ">=12" 1097 + }, 1098 + "funding": { 1099 + "url": "https://github.com/chalk/ansi-regex?sponsor=1" 1100 + } 1101 + }, 1102 + "node_modules/ansi-styles": { 1103 + "version": "6.2.3", 1104 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", 1105 + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", 1106 + "license": "MIT", 1107 + "engines": { 1108 + "node": ">=12" 1109 + }, 1110 + "funding": { 1111 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 736 1112 } 737 1113 }, 738 1114 "node_modules/asynckit": { ··· 771 1147 "node": ">= 0.4" 772 1148 } 773 1149 }, 1150 + "node_modules/chardet": { 1151 + "version": "2.1.1", 1152 + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", 1153 + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", 1154 + "license": "MIT" 1155 + }, 1156 + "node_modules/cli-width": { 1157 + "version": "4.1.0", 1158 + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", 1159 + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", 1160 + "license": "ISC", 1161 + "engines": { 1162 + "node": ">= 12" 1163 + } 1164 + }, 774 1165 "node_modules/collapse-white-space": { 775 1166 "version": "2.1.0", 776 1167 "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", ··· 837 1228 "node": ">= 0.4" 838 1229 } 839 1230 }, 1231 + "node_modules/emoji-regex": { 1232 + "version": "10.6.0", 1233 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", 1234 + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", 1235 + "license": "MIT" 1236 + }, 840 1237 "node_modules/es-define-property": { 841 1238 "version": "1.0.1", 842 1239 "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", ··· 997 1394 "url": "https://github.com/sponsors/ljharb" 998 1395 } 999 1396 }, 1397 + "node_modules/get-east-asian-width": { 1398 + "version": "1.4.0", 1399 + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", 1400 + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", 1401 + "license": "MIT", 1402 + "engines": { 1403 + "node": ">=18" 1404 + }, 1405 + "funding": { 1406 + "url": "https://github.com/sponsors/sindresorhus" 1407 + } 1408 + }, 1000 1409 "node_modules/get-intrinsic": { 1001 1410 "version": "1.3.0", 1002 1411 "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", ··· 1098 1507 "node": ">= 0.4" 1099 1508 } 1100 1509 }, 1510 + "node_modules/iconv-lite": { 1511 + "version": "0.7.1", 1512 + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", 1513 + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", 1514 + "license": "MIT", 1515 + "dependencies": { 1516 + "safer-buffer": ">= 2.1.2 < 3.0.0" 1517 + }, 1518 + "engines": { 1519 + "node": ">=0.10.0" 1520 + }, 1521 + "funding": { 1522 + "type": "opencollective", 1523 + "url": "https://opencollective.com/express" 1524 + } 1525 + }, 1526 + "node_modules/inquirer": { 1527 + "version": "13.1.0", 1528 + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-13.1.0.tgz", 1529 + "integrity": "sha512-4vv4GS/9HLnn0radvmHlXUXiNkd2gYCBQ4U1rxZWBJDisu2Z06bzUM9CFU8pcu1vwuAQjo6O+CFiqCYNsEi6qQ==", 1530 + "license": "MIT", 1531 + "dependencies": { 1532 + "@inquirer/ansi": "^2.0.2", 1533 + "@inquirer/core": "^11.1.0", 1534 + "@inquirer/prompts": "^8.1.0", 1535 + "@inquirer/type": "^4.0.2", 1536 + "mute-stream": "^3.0.0", 1537 + "run-async": "^4.0.6", 1538 + "rxjs": "^7.8.2" 1539 + }, 1540 + "engines": { 1541 + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" 1542 + }, 1543 + "peerDependencies": { 1544 + "@types/node": ">=18" 1545 + }, 1546 + "peerDependenciesMeta": { 1547 + "@types/node": { 1548 + "optional": true 1549 + } 1550 + } 1551 + }, 1101 1552 "node_modules/iso-639-1": { 1102 1553 "version": "3.1.5", 1103 1554 "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.5.tgz", ··· 1170 1621 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 1171 1622 "license": "(Apache-2.0 AND MIT)" 1172 1623 }, 1624 + "node_modules/mute-stream": { 1625 + "version": "3.0.0", 1626 + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", 1627 + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", 1628 + "license": "ISC", 1629 + "engines": { 1630 + "node": "^20.17.0 || >=22.9.0" 1631 + } 1632 + }, 1173 1633 "node_modules/n-gram": { 1174 1634 "version": "2.0.2", 1175 1635 "resolved": "https://registry.npmjs.org/n-gram/-/n-gram-2.0.2.tgz", ··· 1205 1665 "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 1206 1666 } 1207 1667 }, 1668 + "node_modules/run-async": { 1669 + "version": "4.0.6", 1670 + "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", 1671 + "integrity": "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==", 1672 + "license": "MIT", 1673 + "engines": { 1674 + "node": ">=0.12.0" 1675 + } 1676 + }, 1677 + "node_modules/rxjs": { 1678 + "version": "7.8.2", 1679 + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", 1680 + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", 1681 + "license": "Apache-2.0", 1682 + "dependencies": { 1683 + "tslib": "^2.1.0" 1684 + } 1685 + }, 1686 + "node_modules/safer-buffer": { 1687 + "version": "2.1.2", 1688 + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 1689 + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", 1690 + "license": "MIT" 1691 + }, 1692 + "node_modules/signal-exit": { 1693 + "version": "4.1.0", 1694 + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", 1695 + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", 1696 + "license": "ISC", 1697 + "engines": { 1698 + "node": ">=14" 1699 + }, 1700 + "funding": { 1701 + "url": "https://github.com/sponsors/isaacs" 1702 + } 1703 + }, 1704 + "node_modules/string-width": { 1705 + "version": "7.2.0", 1706 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", 1707 + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", 1708 + "license": "MIT", 1709 + "dependencies": { 1710 + "emoji-regex": "^10.3.0", 1711 + "get-east-asian-width": "^1.0.0", 1712 + "strip-ansi": "^7.1.0" 1713 + }, 1714 + "engines": { 1715 + "node": ">=18" 1716 + }, 1717 + "funding": { 1718 + "url": "https://github.com/sponsors/sindresorhus" 1719 + } 1720 + }, 1721 + "node_modules/strip-ansi": { 1722 + "version": "7.1.2", 1723 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", 1724 + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", 1725 + "license": "MIT", 1726 + "dependencies": { 1727 + "ansi-regex": "^6.0.1" 1728 + }, 1729 + "engines": { 1730 + "node": ">=12" 1731 + }, 1732 + "funding": { 1733 + "url": "https://github.com/chalk/strip-ansi?sponsor=1" 1734 + } 1735 + }, 1208 1736 "node_modules/tlds": { 1209 1737 "version": "1.261.0", 1210 1738 "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", ··· 1281 1809 "version": "6.21.0", 1282 1810 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 1283 1811 "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 1284 - "dev": true, 1812 + "devOptional": true, 1285 1813 "license": "MIT" 1286 1814 }, 1287 1815 "node_modules/unicode-segmenter": { ··· 1289 1817 "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 1290 1818 "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 1291 1819 "license": "MIT" 1820 + }, 1821 + "node_modules/wrap-ansi": { 1822 + "version": "9.0.2", 1823 + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", 1824 + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", 1825 + "license": "MIT", 1826 + "dependencies": { 1827 + "ansi-styles": "^6.2.1", 1828 + "string-width": "^7.0.0", 1829 + "strip-ansi": "^7.1.0" 1830 + }, 1831 + "engines": { 1832 + "node": ">=18" 1833 + }, 1834 + "funding": { 1835 + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 1836 + } 1292 1837 }, 1293 1838 "node_modules/zod": { 1294 1839 "version": "3.25.76",
+5 -1
package.json
··· 7 7 "scripts": { 8 8 "build": "tsc", 9 9 "start": "node dist/index.js", 10 + "cli": "tsx src/cli.ts", 10 11 "dev": "tsx src/index.ts", 11 12 "import": "tsx src/index.ts --import-history", 12 13 "lint": "biome check --write .", ··· 27 28 "@atproto/api": "^0.18.9", 28 29 "@steipete/bird": "^0.4.0", 29 30 "axios": "^1.13.2", 31 + "commander": "^14.0.2", 30 32 "dotenv": "^17.2.3", 31 33 "franc-min": "^6.2.0", 34 + "inquirer": "^13.1.0", 32 35 "iso-639-1": "^3.1.2", 33 36 "node-cron": "^4.2.1" 34 37 }, 35 38 "devDependencies": { 36 39 "@biomejs/biome": "^1.9.4", 40 + "@types/inquirer": "^9.0.9", 37 41 "@types/node": "^22.10.2", 38 42 "tsx": "^4.19.2", 39 43 "typescript": "^5.7.2" 40 44 } 41 - } 45 + }
+157
src/cli.ts
··· 1 + import { Command } from 'commander'; 2 + import inquirer from 'inquirer'; 3 + import { addMapping, getConfig, removeMapping, saveConfig, updateTwitterConfig } from './config-manager.js'; 4 + 5 + const program = new Command(); 6 + 7 + program 8 + .name('tweets-2-bsky-cli') 9 + .description('CLI to manage Twitter to Bluesky crossposting mappings') 10 + .version('1.0.0'); 11 + 12 + program 13 + .command('setup-twitter') 14 + .description('Setup Twitter auth cookies') 15 + .action(async () => { 16 + const config = getConfig(); 17 + const answers = await inquirer.prompt([ 18 + { 19 + type: 'input', 20 + name: 'authToken', 21 + message: 'Enter Twitter auth_token:', 22 + default: config.twitter.authToken, 23 + }, 24 + { 25 + type: 'input', 26 + name: 'ct0', 27 + message: 'Enter Twitter ct0:', 28 + default: config.twitter.ct0, 29 + }, 30 + ]); 31 + updateTwitterConfig(answers); 32 + console.log('Twitter config updated!'); 33 + }); 34 + 35 + program 36 + .command('add-mapping') 37 + .description('Add a new Twitter to Bluesky mapping') 38 + .action(async () => { 39 + const answers = await inquirer.prompt([ 40 + { 41 + type: 'input', 42 + name: 'twitterUsername', 43 + message: 'Twitter username to watch (without @):', 44 + }, 45 + { 46 + type: 'input', 47 + name: 'bskyIdentifier', 48 + message: 'Bluesky identifier (handle or email):', 49 + }, 50 + { 51 + type: 'password', 52 + name: 'bskyPassword', 53 + message: 'Bluesky app password:', 54 + }, 55 + { 56 + type: 'input', 57 + name: 'bskyServiceUrl', 58 + message: 'Bluesky service URL:', 59 + default: 'https://bsky.social', 60 + }, 61 + ]); 62 + addMapping(answers); 63 + console.log('Mapping added successfully!'); 64 + }); 65 + 66 + program 67 + .command('list') 68 + .description('List all mappings') 69 + .action(() => { 70 + const config = getConfig(); 71 + if (config.mappings.length === 0) { 72 + console.log('No mappings found.'); 73 + return; 74 + } 75 + console.table( 76 + config.mappings.map((m) => ({ 77 + id: m.id, 78 + twitter: m.twitterUsername, 79 + bsky: m.bskyIdentifier, 80 + enabled: m.enabled, 81 + })), 82 + ); 83 + }); 84 + 85 + program 86 + .command('remove') 87 + .description('Remove a mapping') 88 + .action(async () => { 89 + const config = getConfig(); 90 + if (config.mappings.length === 0) { 91 + console.log('No mappings to remove.'); 92 + return; 93 + } 94 + const { id } = await inquirer.prompt([ 95 + { 96 + type: 'list', 97 + name: 'id', 98 + message: 'Select a mapping to remove:', 99 + choices: config.mappings.map((m) => ({ 100 + name: `${m.twitterUsername} -> ${m.bskyIdentifier}`, 101 + value: m.id, 102 + })), 103 + }, 104 + ]); 105 + removeMapping(id); 106 + console.log('Mapping removed.'); 107 + }); 108 + 109 + program 110 + .command('import-history') 111 + .description('Import history for a specific mapping') 112 + .action(async () => { 113 + const config = getConfig(); 114 + if (config.mappings.length === 0) { 115 + console.log('No mappings found.'); 116 + return; 117 + } 118 + const { id } = await inquirer.prompt([ 119 + { 120 + type: 'list', 121 + name: 'id', 122 + message: 'Select a mapping to import history for:', 123 + choices: config.mappings.map((m) => ({ 124 + name: `${m.twitterUsername} -> ${m.bskyIdentifier}`, 125 + value: m.id, 126 + })), 127 + }, 128 + ]); 129 + 130 + const mapping = config.mappings.find((m) => m.id === id); 131 + if (!mapping) return; 132 + 133 + console.log(` 134 + To import history for ${mapping.twitterUsername}, run:`); 135 + console.log(` npm run import -- --username ${mapping.twitterUsername}`); 136 + console.log(` 137 + You can also use additional flags:`); 138 + console.log(' --limit <number> Limit the number of tweets to import'); 139 + console.log(' --dry-run Fetch and show tweets without posting'); 140 + console.log(` 141 + Example:`); 142 + console.log(` npm run import -- --username ${mapping.twitterUsername} --limit 10 --dry-run 143 + `); 144 + }); 145 + 146 + program 147 + .command('set-interval') 148 + .description('Set check interval in minutes') 149 + .argument('<minutes>', 'Interval in minutes') 150 + .action((minutes) => { 151 + const config = getConfig(); 152 + config.checkIntervalMinutes = Number.parseInt(minutes, 10); 153 + saveConfig(config); 154 + console.log(`Interval set to ${minutes} minutes.`); 155 + }); 156 + 157 + program.parse();
+75
src/config-manager.ts
··· 1 + import fs from 'node:fs'; 2 + import path from 'node:path'; 3 + import { fileURLToPath } from 'node:url'; 4 + 5 + const __filename = fileURLToPath(import.meta.url); 6 + const __dirname = path.dirname(__filename); 7 + 8 + const CONFIG_FILE = path.join(__dirname, '..', 'config.json'); 9 + 10 + export interface TwitterConfig { 11 + authToken: string; 12 + ct0: string; 13 + } 14 + 15 + export interface AccountMapping { 16 + id: string; 17 + twitterUsername: string; 18 + bskyIdentifier: string; 19 + bskyPassword: string; 20 + bskyServiceUrl?: string; 21 + enabled: boolean; 22 + } 23 + 24 + export interface AppConfig { 25 + twitter: TwitterConfig; 26 + mappings: AccountMapping[]; 27 + checkIntervalMinutes: number; 28 + } 29 + 30 + export function getConfig(): AppConfig { 31 + if (!fs.existsSync(CONFIG_FILE)) { 32 + return { 33 + twitter: { authToken: '', ct0: '' }, 34 + mappings: [], 35 + checkIntervalMinutes: 5, 36 + }; 37 + } 38 + try { 39 + return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); 40 + } catch (err) { 41 + console.error('Error reading config:', err); 42 + return { 43 + twitter: { authToken: '', ct0: '' }, 44 + mappings: [], 45 + checkIntervalMinutes: 5, 46 + }; 47 + } 48 + } 49 + 50 + export function saveConfig(config: AppConfig): void { 51 + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); 52 + } 53 + 54 + export function addMapping(mapping: Omit<AccountMapping, 'id' | 'enabled'>): void { 55 + const config = getConfig(); 56 + const newMapping: AccountMapping = { 57 + ...mapping, 58 + id: Math.random().toString(36).substring(7), 59 + enabled: true, 60 + }; 61 + config.mappings.push(newMapping); 62 + saveConfig(config); 63 + } 64 + 65 + export function removeMapping(id: string): void { 66 + const config = getConfig(); 67 + config.mappings = config.mappings.filter((m) => m.id !== id); 68 + saveConfig(config); 69 + } 70 + 71 + export function updateTwitterConfig(twitter: TwitterConfig): void { 72 + const config = getConfig(); 73 + config.twitter = twitter; 74 + saveConfig(config); 75 + }
+438 -504
src/index.ts
··· 7 7 import type { BlobRef } from '@atproto/api'; 8 8 import { TwitterClient } from '@steipete/bird/dist/lib/twitter-client.js'; 9 9 import axios from 'axios'; 10 + import { Command } from 'commander'; 10 11 import * as francModule from 'franc-min'; 11 12 import iso6391 from 'iso-639-1'; 12 13 import cron from 'node-cron'; 14 + import { getConfig } from './config-manager.js'; 13 15 14 16 // ESM __dirname equivalent 15 17 const __filename = fileURLToPath(import.meta.url); 16 18 const __dirname = path.dirname(__filename); 17 19 18 - // ============================================================================ 20 + // ============================================================================ 19 21 // Type Definitions 20 - // ============================================================================ 22 + // ============================================================================ 21 23 22 24 interface ProcessedTweetEntry { 23 - uri?: string; 24 - cid?: string; 25 - root?: { uri: string; cid: string }; 26 - migrated?: boolean; 27 - skipped?: boolean; 25 + uri?: string; 26 + cid?: string; 27 + root?: { uri: string; cid: string }; 28 + migrated?: boolean; 29 + skipped?: boolean; 28 30 } 29 31 30 32 interface ProcessedTweetsMap { 31 - [twitterId: string]: ProcessedTweetEntry; 33 + [twitterId: string]: ProcessedTweetEntry; 32 34 } 33 35 34 36 interface UrlEntity { 35 - url?: string; 36 - expanded_url?: string; 37 + url?: string; 38 + expanded_url?: string; 37 39 } 38 40 39 41 interface MediaSize { 40 - w: number; 41 - h: number; 42 + w: number; 43 + h: number; 42 44 } 43 45 44 46 interface MediaSizes { 45 - large?: MediaSize; 47 + large?: MediaSize; 46 48 } 47 49 48 50 interface OriginalInfo { 49 - width: number; 50 - height: number; 51 + width: number; 52 + height: number; 51 53 } 52 54 53 55 interface VideoVariant { 54 - content_type: string; 55 - url: string; 56 - bitrate?: number; 56 + content_type: string; 57 + url: string; 58 + bitrate?: number; 57 59 } 58 60 59 61 interface VideoInfo { 60 - variants?: VideoVariant[]; 62 + variants?: VideoVariant[]; 61 63 } 62 64 63 65 interface MediaEntity { 64 - url?: string; 65 - expanded_url?: string; 66 - media_url_https?: string; 67 - type?: 'photo' | 'video' | 'animated_gif'; 68 - ext_alt_text?: string; 69 - sizes?: MediaSizes; 70 - original_info?: OriginalInfo; 71 - video_info?: VideoInfo; 66 + url?: string; 67 + expanded_url?: string; 68 + media_url_https?: string; 69 + type?: 'photo' | 'video' | 'animated_gif'; 70 + ext_alt_text?: string; 71 + sizes?: MediaSizes; 72 + original_info?: OriginalInfo; 73 + video_info?: VideoInfo; 72 74 } 73 75 74 76 interface TweetEntities { 75 - urls?: UrlEntity[]; 76 - media?: MediaEntity[]; 77 + urls?: UrlEntity[]; 78 + media?: MediaEntity[]; 77 79 } 78 80 79 81 interface Tweet { 80 - id?: string; 81 - id_str?: string; 82 - text?: string; 83 - full_text?: string; 84 - created_at?: string; 85 - entities?: TweetEntities; 86 - extended_entities?: TweetEntities; 87 - quoted_status_id_str?: string; 88 - is_quote_status?: boolean; 89 - in_reply_to_status_id_str?: string; 90 - in_reply_to_status_id?: string; 91 - in_reply_to_user_id_str?: string; 92 - in_reply_to_user_id?: string; 82 + id?: string; 83 + id_str?: string; 84 + text?: string; 85 + full_text?: string; 86 + created_at?: string; 87 + entities?: TweetEntities; 88 + extended_entities?: TweetEntities; 89 + quoted_status_id_str?: string; 90 + is_quote_status?: boolean; 91 + in_reply_to_status_id_str?: string; 92 + in_reply_to_status_id?: string; 93 + in_reply_to_user_id_str?: string; 94 + in_reply_to_user_id?: string; 93 95 } 94 96 95 97 interface TwitterSearchResult { 96 - success: boolean; 97 - tweets?: Tweet[]; 98 - error?: Error | string; 99 - } 100 - 101 - interface TwitterUserResult { 102 - success: boolean; 103 - user?: { username: string }; 98 + success: boolean; 99 + tweets?: Tweet[]; 100 + error?: Error | string; 104 101 } 105 102 106 103 interface AspectRatio { 107 - width: number; 108 - height: number; 104 + width: number; 105 + height: number; 109 106 } 110 107 111 108 interface ImageEmbed { 112 - alt: string; 113 - image: BlobRef; 114 - aspectRatio?: AspectRatio; 109 + alt: string; 110 + image: BlobRef; 111 + aspectRatio?: AspectRatio; 115 112 } 116 113 117 - // PostRecord is built dynamically for agent.post() 118 - 119 - // ============================================================================ 120 - // Configuration 121 - // ============================================================================ 122 - 123 - const TWITTER_AUTH_TOKEN = process.env.TWITTER_AUTH_TOKEN; 124 - const TWITTER_CT0 = process.env.TWITTER_CT0; 125 - const TWITTER_TARGET_USERNAME = process.env.TWITTER_TARGET_USERNAME; 126 - const BLUESKY_IDENTIFIER = process.env.BLUESKY_IDENTIFIER; 127 - const BLUESKY_PASSWORD = process.env.BLUESKY_PASSWORD; 128 - const BLUESKY_SERVICE_URL = process.env.BLUESKY_SERVICE_URL || 'https://bsky.social'; 129 - const CHECK_INTERVAL_MINUTES = Number(process.env.CHECK_INTERVAL_MINUTES) || 5; 130 - const PROCESSED_TWEETS_FILE = path.join(__dirname, '..', 'processed_tweets.json'); 131 - 132 - // ============================================================================ 114 + // ============================================================================ 133 115 // State Management 134 - // ============================================================================ 116 + // ============================================================================ 135 117 136 - let processedTweets: ProcessedTweetsMap = {}; 118 + const PROCESSED_DIR = path.join(__dirname, '..', 'processed'); 119 + if (!fs.existsSync(PROCESSED_DIR)) { 120 + fs.mkdirSync(PROCESSED_DIR); 121 + } 137 122 138 - function loadProcessedTweets(): void { 139 - try { 140 - if (fs.existsSync(PROCESSED_TWEETS_FILE)) { 141 - const raw: unknown = JSON.parse(fs.readFileSync(PROCESSED_TWEETS_FILE, 'utf8')); 142 - if (Array.isArray(raw)) { 143 - // Migration from v1 (Array of IDs) to v2 (Object map) 144 - console.log('Migrating processed_tweets.json from v1 to v2...'); 145 - processedTweets = (raw as string[]).reduce<ProcessedTweetsMap>((acc, id) => { 146 - acc[id] = { migrated: true }; 147 - return acc; 148 - }, {}); 149 - saveProcessedTweets(); 150 - } else if (typeof raw === 'object' && raw !== null) { 151 - processedTweets = raw as ProcessedTweetsMap; 152 - } 153 - } 154 - } catch (err) { 155 - console.error('Error loading processed tweets:', err); 156 - } 123 + function getProcessedFilePath(twitterUsername: string): string { 124 + return path.join(PROCESSED_DIR, `${twitterUsername.toLowerCase()}.json`); 157 125 } 158 126 159 - function saveProcessedTweets(): void { 160 - try { 161 - fs.writeFileSync(PROCESSED_TWEETS_FILE, JSON.stringify(processedTweets, null, 2)); 162 - } catch (err) { 163 - console.error('Error saving processed tweets:', err); 127 + function loadProcessedTweets(twitterUsername: string): ProcessedTweetsMap { 128 + const filePath = getProcessedFilePath(twitterUsername); 129 + try { 130 + if (fs.existsSync(filePath)) { 131 + return JSON.parse(fs.readFileSync(filePath, 'utf8')); 164 132 } 133 + } catch (err) { 134 + console.error(`Error loading processed tweets for ${twitterUsername}:`, err); 135 + } 136 + return {}; 165 137 } 166 138 167 - loadProcessedTweets(); 168 - 169 - // ============================================================================ 170 - // Bluesky Agent 171 - // ============================================================================ 172 - 173 - const agent = new BskyAgent({ 174 - service: BLUESKY_SERVICE_URL, 175 - }); 139 + function saveProcessedTweets(twitterUsername: string, data: ProcessedTweetsMap): void { 140 + const filePath = getProcessedFilePath(twitterUsername); 141 + try { 142 + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); 143 + } catch (err) { 144 + console.error(`Error saving processed tweets for ${twitterUsername}:`, err); 145 + } 146 + } 176 147 177 - // ============================================================================ 148 + // ============================================================================ 178 149 // Custom Twitter Client 179 - // ============================================================================ 150 + // ============================================================================ 180 151 181 152 interface TwitterLegacyResult { 182 - legacy?: { 183 - entities?: TweetEntities; 184 - extended_entities?: TweetEntities; 185 - quoted_status_id_str?: string; 186 - is_quote_status?: boolean; 187 - in_reply_to_status_id_str?: string; 188 - in_reply_to_user_id_str?: string; 189 - }; 153 + legacy?: { 154 + entities?: TweetEntities; 155 + extended_entities?: TweetEntities; 156 + quoted_status_id_str?: string; 157 + is_quote_status?: boolean; 158 + in_reply_to_status_id_str?: string; 159 + in_reply_to_user_id_str?: string; 160 + }; 190 161 } 191 162 192 163 class CustomTwitterClient extends TwitterClient { 193 - mapTweetResult(result: TwitterLegacyResult): Tweet | null { 194 - // biome-ignore lint/suspicious/noExplicitAny: parent class is untyped 195 - const mapped = (super.mapTweetResult as any)(result) as Tweet | null; 196 - if (mapped && result.legacy) { 197 - mapped.entities = result.legacy.entities; 198 - mapped.extended_entities = result.legacy.extended_entities; 199 - mapped.quoted_status_id_str = result.legacy.quoted_status_id_str; 200 - mapped.is_quote_status = result.legacy.is_quote_status; 201 - mapped.in_reply_to_status_id_str = result.legacy.in_reply_to_status_id_str; 202 - mapped.in_reply_to_user_id_str = result.legacy.in_reply_to_user_id_str; 203 - } 204 - return mapped; 164 + mapTweetResult(result: TwitterLegacyResult): Tweet | null { 165 + // biome-ignore lint/suspicious/noExplicitAny: parent class is untyped 166 + const mapped = (super.mapTweetResult as any)(result) as Tweet | null; 167 + if (mapped && result.legacy) { 168 + mapped.entities = result.legacy.entities; 169 + mapped.extended_entities = result.legacy.extended_entities; 170 + mapped.quoted_status_id_str = result.legacy.quoted_status_id_str; 171 + mapped.is_quote_status = result.legacy.is_quote_status; 172 + mapped.in_reply_to_status_id_str = result.legacy.in_reply_to_status_id_str; 173 + mapped.in_reply_to_user_id_str = result.legacy.in_reply_to_user_id_str; 205 174 } 175 + return mapped; 176 + } 206 177 } 207 178 208 - const twitter = new CustomTwitterClient({ 209 - cookies: { 210 - authToken: TWITTER_AUTH_TOKEN ?? '', 211 - ct0: TWITTER_CT0 ?? '', 212 - }, 213 - }); 179 + let twitter: CustomTwitterClient; 214 180 215 - // ============================================================================ 181 + // ============================================================================ 216 182 // Helper Functions 217 - // ============================================================================ 183 + // ============================================================================ 218 184 219 185 function detectLanguage(text: string): string[] { 220 - if (!text || text.trim().length === 0) return ['en']; 221 - try { 222 - const code3 = (francModule as unknown as (text: string) => string)(text); 223 - if (code3 === 'und') return ['en']; 224 - const code2 = iso6391.getCode(code3); 225 - return code2 ? [code2] : ['en']; 226 - } catch { 227 - return ['en']; 228 - } 186 + if (!text || text.trim().length === 0) return ['en']; 187 + try { 188 + const code3 = (francModule as unknown as (text: string) => string)(text); 189 + if (code3 === 'und') return ['en']; 190 + const code2 = iso6391.getCode(code3); 191 + return code2 ? [code2] : ['en']; 192 + } catch { 193 + return ['en']; 194 + } 229 195 } 230 196 231 197 async function expandUrl(shortUrl: string): Promise<string> { 198 + try { 199 + const response = await axios.head(shortUrl, { 200 + maxRedirects: 10, 201 + validateStatus: (status) => status >= 200 && status < 400, 202 + }); 203 + // biome-ignore lint/suspicious/noExplicitAny: axios internal types 204 + return (response.request as any)?.res?.responseUrl || shortUrl; 205 + } catch { 232 206 try { 233 - const response = await axios.head(shortUrl, { 234 - maxRedirects: 10, 235 - validateStatus: (status) => status >= 200 && status < 400, 236 - }); 237 - // biome-ignore lint/suspicious/noExplicitAny: axios internal types 238 - return (response.request as any)?.res?.responseUrl || shortUrl; 207 + const response = await axios.get(shortUrl, { 208 + responseType: 'stream', 209 + maxRedirects: 10, 210 + }); 211 + response.data.destroy(); 212 + // biome-ignore lint/suspicious/noExplicitAny: axios internal types 213 + return (response.request as any)?.res?.responseUrl || shortUrl; 239 214 } catch { 240 - try { 241 - const response = await axios.get(shortUrl, { 242 - responseType: 'stream', 243 - maxRedirects: 10, 244 - }); 245 - response.data.destroy(); 246 - // biome-ignore lint/suspicious/noExplicitAny: axios internal types 247 - return (response.request as any)?.res?.responseUrl || shortUrl; 248 - } catch { 249 - return shortUrl; 250 - } 215 + return shortUrl; 251 216 } 217 + } 252 218 } 253 219 254 220 interface DownloadedMedia { 255 - buffer: Buffer; 256 - mimeType: string; 221 + buffer: Buffer; 222 + mimeType: string; 257 223 } 258 224 259 225 async function downloadMedia(url: string): Promise<DownloadedMedia> { 260 - const response = await axios({ 261 - url, 262 - method: 'GET', 263 - responseType: 'arraybuffer', 264 - }); 265 - return { 266 - buffer: Buffer.from(response.data as ArrayBuffer), 267 - mimeType: (response.headers['content-type'] as string) || 'application/octet-stream', 268 - }; 226 + const response = await axios({ 227 + url, 228 + method: 'GET', 229 + responseType: 'arraybuffer', 230 + }); 231 + return { 232 + buffer: Buffer.from(response.data as ArrayBuffer), 233 + mimeType: (response.headers['content-type'] as string) || 'application/octet-stream', 234 + }; 269 235 } 270 236 271 - async function uploadToBluesky(buffer: Buffer, mimeType: string): Promise<BlobRef> { 272 - const { data } = await agent.uploadBlob(buffer, { encoding: mimeType }); 273 - return data.blob; 274 - } 275 - 276 - async function getUsername(): Promise<string> { 277 - if (TWITTER_TARGET_USERNAME) return TWITTER_TARGET_USERNAME; 278 - try { 279 - const res = (await twitter.getCurrentUser()) as TwitterUserResult; 280 - if (res.success && res.user) { 281 - return res.user.username; 282 - } 283 - } catch (e) { 284 - console.warn("Failed to get 'whoami'. defaulting to 'me'.", (e as Error).message); 285 - } 286 - return 'me'; 237 + async function uploadToBluesky(agent: BskyAgent, buffer: Buffer, mimeType: string): Promise<BlobRef> { 238 + const { data } = await agent.uploadBlob(buffer, { encoding: mimeType }); 239 + return data.blob; 287 240 } 288 241 289 242 function getRandomDelay(min = 1000, max = 4000): number { 290 - return Math.floor(Math.random() * (max - min + 1) + min); 243 + return Math.floor(Math.random() * (max - min + 1) + min); 291 244 } 292 245 293 246 function refreshQueryIds(): Promise<void> { 294 - return new Promise((resolve) => { 295 - console.log("⚠️ Attempting to refresh Twitter Query IDs via 'bird' CLI..."); 296 - exec('./node_modules/.bin/bird query-ids --fresh', (error, _stdout, stderr) => { 297 - if (error) { 298 - console.error(`Error refreshing IDs: ${error.message}`); 299 - console.error(`Stderr: ${stderr}`); 300 - } else { 301 - console.log('✅ Query IDs refreshed successfully.'); 302 - } 303 - resolve(); 304 - }); 247 + return new Promise((resolve) => { 248 + console.log("⚠️ Attempting to refresh Twitter Query IDs via 'bird' CLI..."); 249 + exec('./node_modules/.bin/bird query-ids --fresh', (error, _stdout, stderr) => { 250 + if (error) { 251 + console.error(`Error refreshing IDs: ${error.message}`); 252 + console.error(`Stderr: ${stderr}`); 253 + } else { 254 + console.log('✅ Query IDs refreshed successfully.'); 255 + } 256 + resolve(); 305 257 }); 258 + }); 306 259 } 307 260 308 - /** 309 - * Wraps twitter.search with auto-recovery for stale Query IDs 310 - */ 311 261 async function safeSearch(query: string, limit: number): Promise<TwitterSearchResult> { 312 - try { 313 - const result = (await twitter.search(query, limit)) as TwitterSearchResult; 314 - if (!result.success && result.error) { 315 - const errorStr = result.error.toString(); 316 - if (errorStr.includes('GraphQL') || errorStr.includes('404')) { 317 - throw new Error(errorStr); 318 - } 319 - } 320 - return result; 321 - } catch (err) { 322 - const error = err as Error; 323 - console.warn(`Search encountered an error: ${error.message || err}`); 324 - if ( 325 - error.message && 326 - (error.message.includes('GraphQL') || error.message.includes('404') || error.message.includes('Bad Guest Token')) 327 - ) { 328 - await refreshQueryIds(); 329 - console.log('Retrying search...'); 330 - return (await twitter.search(query, limit)) as TwitterSearchResult; 331 - } 332 - return { success: false, error }; 262 + try { 263 + const result = (await twitter.search(query, limit)) as TwitterSearchResult; 264 + if (!result.success && result.error) { 265 + const errorStr = result.error.toString(); 266 + if (errorStr.includes('GraphQL') || errorStr.includes('404')) { 267 + throw new Error(errorStr); 268 + } 269 + } 270 + return result; 271 + } catch (err) { 272 + const error = err as Error; 273 + console.warn(`Search encountered an error: ${error.message || err}`); 274 + if ( 275 + error.message && 276 + (error.message.includes('GraphQL') || error.message.includes('404') || error.message.includes('Bad Guest Token')) 277 + ) { 278 + await refreshQueryIds(); 279 + console.log('Retrying search...'); 280 + return (await twitter.search(query, limit)) as TwitterSearchResult; 333 281 } 282 + return { success: false, error }; 283 + } 334 284 } 335 285 336 - // ============================================================================ 286 + // ============================================================================ 337 287 // Main Processing Logic 338 - // ============================================================================ 288 + // ============================================================================ 339 289 340 - async function processTweets(tweets: Tweet[]): Promise<void> { 341 - // Ensure chronological order 342 - tweets.reverse(); 290 + async function processTweets( 291 + agent: BskyAgent, 292 + twitterUsername: string, 293 + tweets: Tweet[], 294 + dryRun = false, 295 + ): Promise<void> { 296 + const processedTweets = loadProcessedTweets(twitterUsername); 297 + tweets.reverse(); 343 298 344 - for (const tweet of tweets) { 345 - const tweetId = tweet.id_str || tweet.id; 346 - if (!tweetId) continue; 347 - 348 - if (processedTweets[tweetId]) { 349 - continue; 350 - } 351 - 352 - // --- Filter Replies (unless we are maintaining a thread) --- 353 - const replyStatusId = tweet.in_reply_to_status_id_str || tweet.in_reply_to_status_id; 354 - const replyUserId = tweet.in_reply_to_user_id_str || tweet.in_reply_to_user_id; 355 - const tweetText = tweet.full_text || tweet.text || ''; 356 - const isReply = !!replyStatusId || !!replyUserId || tweetText.trim().startsWith('@'); 357 - 358 - let replyParentInfo: ProcessedTweetEntry | null = null; 359 - 360 - if (isReply) { 361 - const parentEntry = replyStatusId ? processedTweets[replyStatusId] : undefined; 362 - // Only thread if parent was successfully posted (has uri/cid) and not migrated/skipped 363 - if (parentEntry && parentEntry.uri && parentEntry.cid && !parentEntry.migrated && !parentEntry.skipped) { 364 - console.log(`Threading reply to ${replyStatusId}`); 365 - replyParentInfo = parentEntry; 366 - } else { 367 - // Reply to unknown, external, or skipped tweet -> Skip 368 - console.log(`Skipping reply: ${tweetId}`); 369 - processedTweets[tweetId] = { skipped: true }; 370 - saveProcessedTweets(); 371 - continue; 372 - } 373 - } 299 + for (const tweet of tweets) { 300 + const tweetId = tweet.id_str || tweet.id; 301 + if (!tweetId) continue; 374 302 375 - console.log(`Processing tweet: ${tweetId}`); 303 + if (processedTweets[tweetId]) continue; 376 304 377 - let text = tweetText; 305 + const replyStatusId = tweet.in_reply_to_status_id_str || tweet.in_reply_to_status_id; 306 + const replyUserId = tweet.in_reply_to_user_id_str || tweet.in_reply_to_user_id; 307 + const tweetText = tweet.full_text || tweet.text || ''; 308 + const isReply = !!replyStatusId || !!replyUserId || tweetText.trim().startsWith('@'); 378 309 379 - // --- 1. Link Expansion --- 380 - const urls = tweet.entities?.urls || []; 381 - for (const urlEntity of urls) { 382 - const tco = urlEntity.url; 383 - const expanded = urlEntity.expanded_url; 384 - if (tco && expanded) { 385 - text = text.replace(tco, expanded); 386 - } 387 - } 310 + let replyParentInfo: ProcessedTweetEntry | null = null; 388 311 389 - // Manual cleanup of remaining t.co 390 - const tcoRegex = /https:\/\/t\.co\/[a-zA-Z0-9]+/g; 391 - const matches = text.match(tcoRegex) || []; 392 - for (const tco of matches) { 393 - const resolved = await expandUrl(tco); 394 - if (resolved !== tco) { 395 - text = text.replace(tco, resolved); 396 - } 312 + if (isReply) { 313 + if (replyStatusId && processedTweets[replyStatusId] && !processedTweets[replyStatusId]?.migrated) { 314 + replyParentInfo = processedTweets[replyStatusId] ?? null; 315 + } else { 316 + if (!dryRun) { 317 + processedTweets[tweetId] = { skipped: true }; 318 + saveProcessedTweets(twitterUsername, processedTweets); 397 319 } 320 + continue; 321 + } 322 + } 398 323 399 - // --- 2. Media Handling --- 400 - const images: ImageEmbed[] = []; 401 - let videoBlob: BlobRef | null = null; 402 - let videoAspectRatio: AspectRatio | undefined; 324 + console.log(`[${twitterUsername}] ${dryRun ? '[DRY RUN] ' : ''}Processing tweet: ${tweetId}`); 403 325 404 - const mediaEntities = tweet.extended_entities?.media || tweet.entities?.media || []; 405 - const mediaLinksToRemove: string[] = []; 326 + if (dryRun) { 327 + console.log(`[DRY RUN] Content: ${tweetText.substring(0, 100)}...`); 328 + continue; 329 + } 406 330 407 - for (const media of mediaEntities) { 408 - if (media.url) { 409 - mediaLinksToRemove.push(media.url); 410 - if (media.expanded_url) mediaLinksToRemove.push(media.expanded_url); 411 - } 331 + let text = tweetText; 332 + const urls = tweet.entities?.urls || []; 333 + for (const urlEntity of urls) { 334 + const tco = urlEntity.url; 335 + const expanded = urlEntity.expanded_url; 336 + if (tco && expanded) text = text.replace(tco, expanded); 337 + } 412 338 413 - // Aspect Ratio Extraction 414 - let aspectRatio: AspectRatio | undefined; 415 - if (media.sizes?.large) { 416 - aspectRatio = { width: media.sizes.large.w, height: media.sizes.large.h }; 417 - } else if (media.original_info) { 418 - aspectRatio = { width: media.original_info.width, height: media.original_info.height }; 419 - } 339 + const tcoRegex = /https:\/\/t\.co\/[a-zA-Z0-9]+/g; 340 + const matches = text.match(tcoRegex) || []; 341 + for (const tco of matches) { 342 + const resolved = await expandUrl(tco); 343 + if (resolved !== tco) text = text.replace(tco, resolved); 344 + } 420 345 421 - if (media.type === 'photo') { 422 - const url = media.media_url_https; 423 - if (!url) continue; 424 - try { 425 - const { buffer, mimeType } = await downloadMedia(url); 426 - const blob = await uploadToBluesky(buffer, mimeType); 427 - images.push({ 428 - alt: media.ext_alt_text || 'Image from Twitter', 429 - image: blob, 430 - aspectRatio, 431 - }); 432 - } catch (err) { 433 - console.error(`Failed to upload image ${url}:`, (err as Error).message); 434 - } 435 - } else if (media.type === 'video' || media.type === 'animated_gif') { 436 - const variants = media.video_info?.variants || []; 437 - const mp4s = variants 438 - .filter((v) => v.content_type === 'video/mp4') 439 - .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); 346 + const images: ImageEmbed[] = []; 347 + let videoBlob: BlobRef | null = null; 348 + let videoAspectRatio: AspectRatio | undefined; 349 + const mediaEntities = tweet.extended_entities?.media || tweet.entities?.media || []; 350 + const mediaLinksToRemove: string[] = []; 440 351 441 - if (mp4s.length > 0 && mp4s[0]) { 442 - const videoUrl = mp4s[0].url; 443 - try { 444 - const { buffer, mimeType } = await downloadMedia(videoUrl); 352 + for (const media of mediaEntities) { 353 + if (media.url) { 354 + mediaLinksToRemove.push(media.url); 355 + if (media.expanded_url) mediaLinksToRemove.push(media.expanded_url); 356 + } 357 + let aspectRatio: AspectRatio | undefined; 358 + if (media.sizes?.large) { 359 + aspectRatio = { width: media.sizes.large.w, height: media.sizes.large.h }; 360 + } else if (media.original_info) { 361 + aspectRatio = { width: media.original_info.width, height: media.original_info.height }; 362 + } 445 363 446 - if (buffer.length > 95 * 1024 * 1024) { 447 - console.warn('Video too large (>95MB). Linking instead.'); 448 - text += `\n[Video: ${media.media_url_https}]`; 449 - continue; 450 - } 364 + if (media.type === 'photo') { 365 + const url = media.media_url_https; 366 + if (!url) continue; 367 + try { 368 + const { buffer, mimeType } = await downloadMedia(url); 369 + const blob = await uploadToBluesky(agent, buffer, mimeType); 370 + images.push({ alt: media.ext_alt_text || 'Image from Twitter', image: blob, aspectRatio }); 371 + } catch (err) { 372 + console.error(`Failed to upload image ${url}:`, (err as Error).message); 373 + } 374 + } else if (media.type === 'video' || media.type === 'animated_gif') { 375 + const variants = media.video_info?.variants || []; 376 + const mp4s = variants 377 + .filter((v) => v.content_type === 'video/mp4') 378 + .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); 451 379 452 - const blob = await uploadToBluesky(buffer, mimeType); 453 - videoBlob = blob; 454 - videoAspectRatio = aspectRatio; 455 - break; 456 - } catch (err) { 457 - console.error(`Failed to upload video ${videoUrl}:`, (err as Error).message); 458 - text += `\n${media.media_url_https}`; 459 - } 460 - } 380 + if (mp4s.length > 0 && mp4s[0]) { 381 + const videoUrl = mp4s[0].url; 382 + try { 383 + const { buffer, mimeType } = await downloadMedia(videoUrl); 384 + if (buffer.length > 95 * 1024 * 1024) { 385 + text += `\n[Video: ${media.media_url_https}]`; 386 + continue; 461 387 } 388 + const blob = await uploadToBluesky(agent, buffer, mimeType); 389 + videoBlob = blob; 390 + videoAspectRatio = aspectRatio; 391 + break; 392 + } catch (err) { 393 + console.error(`Failed to upload video ${videoUrl}:`, (err as Error).message); 394 + text += `\n${media.media_url_https}`; 395 + } 462 396 } 397 + } 398 + } 463 399 464 - // Remove media links from text 465 - for (const link of mediaLinksToRemove) { 466 - text = text.split(link).join('').trim(); 467 - } 468 - text = text.replace(/\n\s*\n/g, '\n\n').trim(); 400 + for (const link of mediaLinksToRemove) text = text.split(link).join('').trim(); 401 + text = text.replace(/\n\s*\n/g, '\n\n').trim(); 469 402 470 - // --- 3. Quoting Logic --- 471 - let quoteEmbed: { $type: string; record: { uri: string; cid: string } } | null = null; 472 - if (tweet.is_quote_status && tweet.quoted_status_id_str) { 473 - const quoteId = tweet.quoted_status_id_str; 474 - const quoteRef = processedTweets[quoteId]; 475 - if (quoteRef && !quoteRef.migrated && quoteRef.uri && quoteRef.cid) { 476 - quoteEmbed = { 477 - $type: 'app.bsky.embed.record', 478 - record: { 479 - uri: quoteRef.uri, 480 - cid: quoteRef.cid, 481 - }, 482 - }; 483 - } 484 - } 403 + let quoteEmbed: { $type: string; record: { uri: string; cid: string } } | null = null; 404 + if (tweet.is_quote_status && tweet.quoted_status_id_str) { 405 + const quoteId = tweet.quoted_status_id_str; 406 + const quoteRef = processedTweets[quoteId]; 407 + if (quoteRef && !quoteRef.migrated && quoteRef.uri && quoteRef.cid) { 408 + quoteEmbed = { $type: 'app.bsky.embed.record', record: { uri: quoteRef.uri, cid: quoteRef.cid } }; 409 + } 410 + } 485 411 486 - // --- 4. Construct Post --- 487 - const rt = new RichText({ text }); 488 - await rt.detectFacets(agent); 489 - const detectedLangs = detectLanguage(text); 412 + const rt = new RichText({ text }); 413 + await rt.detectFacets(agent); 414 + const detectedLangs = detectLanguage(text); 490 415 491 - // biome-ignore lint/suspicious/noExplicitAny: dynamic record construction 492 - const postRecord: Record<string, any> = { 493 - text: rt.text, 494 - facets: rt.facets, 495 - langs: detectedLangs, 496 - createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString(), 497 - }; 416 + // biome-ignore lint/suspicious/noExplicitAny: dynamic record construction 417 + const postRecord: Record<string, any> = { 418 + text: rt.text, 419 + facets: rt.facets, 420 + langs: detectedLangs, 421 + createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString(), 422 + }; 498 423 499 - // Attach Embeds 500 - if (videoBlob) { 501 - postRecord.embed = { 502 - $type: 'app.bsky.embed.video', 503 - video: videoBlob, 504 - aspectRatio: videoAspectRatio, 505 - }; 506 - } else if (images.length > 0) { 507 - const imagesEmbed = { 508 - $type: 'app.bsky.embed.images', 509 - images, 510 - }; 424 + if (videoBlob) { 425 + postRecord.embed = { $type: 'app.bsky.embed.video', video: videoBlob, aspectRatio: videoAspectRatio }; 426 + } else if (images.length > 0) { 427 + const imagesEmbed = { $type: 'app.bsky.embed.images', images }; 428 + if (quoteEmbed) { 429 + postRecord.embed = { $type: 'app.bsky.embed.recordWithMedia', media: imagesEmbed, record: quoteEmbed }; 430 + } else { 431 + postRecord.embed = imagesEmbed; 432 + } 433 + } else if (quoteEmbed) { 434 + postRecord.embed = quoteEmbed; 435 + } 511 436 512 - if (quoteEmbed) { 513 - postRecord.embed = { 514 - $type: 'app.bsky.embed.recordWithMedia', 515 - media: imagesEmbed, 516 - record: quoteEmbed, 517 - }; 518 - } else { 519 - postRecord.embed = imagesEmbed; 520 - } 521 - } else if (quoteEmbed) { 522 - postRecord.embed = quoteEmbed; 523 - } 437 + if (replyParentInfo?.uri && replyParentInfo?.cid) { 438 + postRecord.reply = { 439 + root: replyParentInfo.root || { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, 440 + parent: { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, 441 + }; 442 + } 524 443 525 - // Attach Reply info 526 - if (replyParentInfo?.uri && replyParentInfo?.cid) { 527 - postRecord.reply = { 528 - root: replyParentInfo.root || { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, 529 - parent: { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, 530 - }; 531 - } 444 + try { 445 + const response = await agent.post(postRecord); 446 + processedTweets[tweetId] = { 447 + uri: response.uri, 448 + cid: response.cid, 449 + root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid }, 450 + }; 451 + saveProcessedTweets(twitterUsername, processedTweets); 452 + await new Promise((r) => setTimeout(r, getRandomDelay(1000, 4000))); 453 + } catch (err) { 454 + console.error(`Failed to post ${tweetId}:`, err); 455 + } 456 + } 457 + } 532 458 533 - // --- 5. Post & Save --- 534 - try { 535 - const response = await agent.post(postRecord); 459 + const activeAgents = new Map<string, BskyAgent>(); 536 460 537 - const newEntry: ProcessedTweetEntry = { 538 - uri: response.uri, 539 - cid: response.cid, 540 - root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid }, 541 - }; 461 + async function getAgent(mapping: { 462 + bskyIdentifier: string; 463 + bskyPassword: string; 464 + bskyServiceUrl?: string; 465 + }): Promise<BskyAgent | null> { 466 + const serviceUrl = mapping.bskyServiceUrl || 'https://bsky.social'; 467 + const cacheKey = `${mapping.bskyIdentifier}-${serviceUrl}`; 468 + const existing = activeAgents.get(cacheKey); 469 + if (existing) return existing; 542 470 543 - processedTweets[tweetId] = newEntry; 544 - saveProcessedTweets(); 545 - 546 - // Random Pacing (1s - 4s) 547 - const sleepTime = getRandomDelay(1000, 4000); 548 - await new Promise((r) => setTimeout(r, sleepTime)); 549 - } catch (err) { 550 - console.error(`Failed to post ${tweetId}:`, err); 551 - } 552 - } 471 + const agent = new BskyAgent({ service: serviceUrl }); 472 + try { 473 + await agent.login({ identifier: mapping.bskyIdentifier, password: mapping.bskyPassword }); 474 + activeAgents.set(cacheKey, agent); 475 + return agent; 476 + } catch (err) { 477 + console.error(`Failed to login to Bluesky for ${mapping.bskyIdentifier} on ${serviceUrl}:`, err); 478 + return null; 479 + } 553 480 } 554 481 555 - async function checkAndPost(): Promise<void> { 556 - console.log(`[${new Date().toISOString()}] Checking...`); 557 - 558 - try { 559 - const username = await getUsername(); 482 + async function checkAndPost(dryRun = false): Promise<void> { 483 + const config = getConfig(); 484 + if (config.mappings.length === 0) return; 560 485 561 - const query = `from:${username}`; 562 - const result = await safeSearch(query, 30); 486 + console.log(`[${new Date().toISOString()}] Checking all accounts...`); 563 487 564 - if (!result.success) { 565 - console.error('Failed to fetch tweets:', result.error); 566 - return; 567 - } 488 + for (const mapping of config.mappings) { 489 + if (!mapping.enabled) continue; 490 + try { 491 + const agent = await getAgent(mapping); 492 + if (!agent) continue; 568 493 569 - const tweets = result.tweets || []; 570 - if (tweets.length === 0) return; 494 + const result = await safeSearch(`from:${mapping.twitterUsername}`, 30); 495 + if (!result.success || !result.tweets) continue; 571 496 572 - await processTweets(tweets); 497 + await processTweets(agent, mapping.twitterUsername, result.tweets, dryRun); 573 498 } catch (err) { 574 - console.error('Error in checkAndPost:', err); 499 + console.error(`Error processing mapping ${mapping.twitterUsername}:`, err); 575 500 } 501 + } 576 502 } 577 503 578 - async function importHistory(): Promise<void> { 579 - console.log('Starting full history import...'); 580 - const username = await getUsername(); 581 - console.log(`Importing history for: ${username}`); 504 + async function importHistory(twitterUsername: string, limit?: number, dryRun = false): Promise<void> { 505 + const config = getConfig(); 506 + const mapping = config.mappings.find((m) => m.twitterUsername.toLowerCase() === twitterUsername.toLowerCase()); 507 + if (!mapping) { 508 + console.error(`No mapping found for twitter username: ${twitterUsername}`); 509 + return; 510 + } 511 + 512 + const agent = await getAgent(mapping); 513 + if (!agent) return; 582 514 583 - let maxId: string | null = null; 584 - const keepGoing = true; 585 - const count = 100; 586 - const allFoundTweets: Tweet[] = []; 587 - const seenIds = new Set<string>(); 515 + console.log(`Starting full history import for ${twitterUsername} -> ${mapping.bskyIdentifier}...`); 588 516 589 - while (keepGoing) { 590 - let query = `from:${username}`; 591 - if (maxId) { 592 - query += ` max_id:${maxId}`; 593 - } 517 + let maxId: string | null = null; 518 + const batchSize = 100; 519 + const allFoundTweets: Tweet[] = []; 520 + const seenIds = new Set<string>(); 521 + const processedTweets = loadProcessedTweets(twitterUsername); 594 522 595 - console.log(`Fetching batch... (Collected: ${allFoundTweets.length})`); 523 + while (true) { 524 + let query = `from:${twitterUsername}`; 525 + if (maxId) query += ` max_id:${maxId}`; 596 526 597 - const result = await safeSearch(query, count); 527 + console.log(`Fetching batch... (Collected: ${allFoundTweets.length})`); 528 + const result = await safeSearch(query, batchSize); 598 529 599 - if (!result.success) { 600 - console.error('Fetch failed:', result.error); 601 - break; 602 - } 530 + if (!result.success || !result.tweets || result.tweets.length === 0) break; 603 531 604 - const tweets = result.tweets || []; 605 - if (tweets.length === 0) break; 532 + let newOnes = 0; 533 + for (const t of result.tweets) { 534 + const tid = t.id_str || t.id; 535 + if (!tid) continue; 536 + if (!processedTweets[tid] && !seenIds.has(tid)) { 537 + allFoundTweets.push(t); 538 + seenIds.add(tid); 539 + newOnes++; 606 540 607 - let newOnes = 0; 608 - for (const t of tweets) { 609 - const tid = t.id_str || t.id; 610 - if (!tid) continue; 611 - if (!processedTweets[tid] && !seenIds.has(tid)) { 612 - allFoundTweets.push(t); 613 - seenIds.add(tid); 614 - newOnes++; 615 - } 616 - } 541 + if (limit && allFoundTweets.length >= limit) break; 542 + } 543 + } 617 544 618 - if (newOnes === 0 && tweets.length > 0) { 619 - const lastTweet = tweets[tweets.length - 1]; 620 - const lastId = lastTweet?.id_str || lastTweet?.id; 621 - if (lastId === maxId) break; 622 - } 545 + if (newOnes === 0 || (limit && allFoundTweets.length >= limit)) break; 623 546 624 - const lastTweet = tweets[tweets.length - 1]; 625 - maxId = lastTweet?.id_str || lastTweet?.id || null; 547 + const lastTweet = result.tweets[result.tweets.length - 1]; 548 + maxId = lastTweet?.id_str || lastTweet?.id || null; 549 + await new Promise((r) => setTimeout(r, 2000)); 550 + } 626 551 627 - // Rate limit protection 628 - await new Promise((r) => setTimeout(r, 2000)); 629 - } 552 + console.log(`Fetch complete. Found ${allFoundTweets.length} new tweets to import.`); 553 + if (allFoundTweets.length > 0) { 554 + await processTweets(agent, twitterUsername, allFoundTweets, dryRun); 555 + console.log('History import complete.'); 556 + } 557 + } 630 558 631 - console.log(`Fetch complete. Found ${allFoundTweets.length} new tweets to import.`); 559 + async function main(): Promise<void> { 560 + const program = new Command(); 561 + program 562 + .name('tweets-2-bsky') 563 + .description('Crosspost tweets to Bluesky') 564 + .option('--dry-run', 'Fetch tweets but do not post to Bluesky', false) 565 + .option('--import-history', 'Run in history import mode') 566 + .option('--username <username>', 'Twitter username for history import') 567 + .option('--limit <number>', 'Limit the number of tweets to import', (val) => Number.parseInt(val, 10)) 568 + .parse(process.argv); 632 569 633 - if (allFoundTweets.length > 0) { 634 - console.log('Starting processing (Oldest -> Newest) with random pacing...'); 635 - await processTweets(allFoundTweets); 636 - console.log('History import complete.'); 637 - } else { 638 - console.log('Nothing new to import.'); 639 - } 640 - } 570 + const options = program.opts(); 641 571 642 - // ============================================================================ 643 - // Entry Point 644 - // ============================================================================ 572 + const config = getConfig(); 573 + if (!config.twitter.authToken || !config.twitter.ct0) { 574 + console.error('Twitter credentials not set. Use "npm run cli setup-twitter".'); 575 + process.exit(1); 576 + } 645 577 646 - async function main(): Promise<void> { 647 - if (!TWITTER_AUTH_TOKEN || !TWITTER_CT0 || !BLUESKY_IDENTIFIER || !BLUESKY_PASSWORD) { 648 - console.error('Missing credentials in .env file.'); 649 - process.exit(1); 650 - } 578 + twitter = new CustomTwitterClient({ 579 + cookies: { 580 + authToken: config.twitter.authToken, 581 + ct0: config.twitter.ct0, 582 + }, 583 + }); 651 584 652 - try { 653 - await agent.login({ identifier: BLUESKY_IDENTIFIER, password: BLUESKY_PASSWORD }); 654 - console.log('Logged in to Bluesky.'); 655 - } catch (err) { 656 - console.error('Failed to login to Bluesky:', err); 657 - process.exit(1); 585 + if (options.importHistory) { 586 + if (!options.username) { 587 + console.error('Please specify a username with --username <username>'); 588 + process.exit(1); 658 589 } 590 + await importHistory(options.username, options.limit, options.dryRun); 591 + process.exit(0); 592 + } 659 593 660 - if (process.argv.includes('--import-history')) { 661 - await importHistory(); 662 - process.exit(0); 663 - } 594 + await checkAndPost(options.dryRun); 664 595 665 - await checkAndPost(); 596 + if (options.dryRun) { 597 + console.log('Dry run complete. Exiting.'); 598 + process.exit(0); 599 + } 666 600 667 - console.log(`Scheduling check every ${CHECK_INTERVAL_MINUTES} minutes.`); 668 - cron.schedule(`*/${CHECK_INTERVAL_MINUTES} * * * *`, () => { 669 - checkAndPost(); 670 - }); 601 + console.log(`Scheduling check every ${config.checkIntervalMinutes} minutes.`); 602 + cron.schedule(`*/${config.checkIntervalMinutes} * * * *`, () => { 603 + checkAndPost(options.dryRun); 604 + }); 671 605 } 672 606 673 - main(); 607 + main();