/dev/null
409 | check_http_response
410 |
411 | local TYPE=$(sed -n 's/{".tag": *"*\([^"]*\)"*.*/\1/p' "$RESPONSE_FILE")
412 |
413 | case $TYPE in
414 |
415 | file)
416 | echo "FILE"
417 | ;;
418 |
419 | folder)
420 | echo "DIR"
421 | ;;
422 |
423 | deleted)
424 | echo "ERR"
425 | ;;
426 |
427 | *)
428 | echo "ERR"
429 | ;;
430 |
431 | esac
432 | }
433 |
434 | #Generic upload wrapper around db_upload_file and db_upload_dir functions
435 | #$1 = Local source file/dir
436 | #$2 = Remote destination file/dir
437 | function db_upload
438 | {
439 | local SRC=$(normalize_path "$1")
440 | local DST=$(normalize_path "$2")
441 |
442 | for j in "${EXCLUDE[@]}"
443 | do :
444 | if [[ $(echo "$SRC" | grep "$j" | wc -l) -gt 0 ]]; then
445 | print "Skipping excluded file/dir: "$j
446 | return
447 | fi
448 | done
449 |
450 | #Checking if the file/dir exists
451 | if [[ ! -e $SRC && ! -d $SRC ]]; then
452 | print " > No such file or directory: $SRC\n"
453 | ERROR_STATUS=1
454 | return
455 | fi
456 |
457 | #Checking if the file/dir has read permissions
458 | if [[ ! -r $SRC ]]; then
459 | print " > Error reading file $SRC: permission denied\n"
460 | ERROR_STATUS=1
461 | return
462 | fi
463 |
464 | TYPE=$(db_stat "$DST")
465 |
466 | #If DST it's a file, do nothing, it's the default behaviour
467 | if [[ $TYPE == "FILE" ]]; then
468 | DST="$DST"
469 |
470 | #if DST doesn't exists and doesn't ends with a /, it will be the destination file name
471 | elif [[ $TYPE == "ERR" && "${DST: -1}" != "/" ]]; then
472 | DST="$DST"
473 |
474 | #if DST doesn't exists and ends with a /, it will be the destination folder
475 | elif [[ $TYPE == "ERR" && "${DST: -1}" == "/" ]]; then
476 | local filename=$(basename "$SRC")
477 | DST="$DST/$filename"
478 |
479 | #If DST it's a directory, it will be the destination folder
480 | elif [[ $TYPE == "DIR" ]]; then
481 | local filename=$(basename "$SRC")
482 | DST="$DST/$filename"
483 | fi
484 |
485 | #It's a directory
486 | if [[ -d $SRC ]]; then
487 | db_upload_dir "$SRC" "$DST"
488 |
489 | #It's a file
490 | elif [[ -e $SRC ]]; then
491 | db_upload_file "$SRC" "$DST"
492 |
493 | #Unsupported object...
494 | else
495 | print " > Skipping not regular file \"$SRC\"\n"
496 | fi
497 | }
498 |
499 | #Generic upload wrapper around db_chunked_upload_file and db_simple_upload_file
500 | #The final upload function will be choosen based on the file size
501 | #$1 = Local source file
502 | #$2 = Remote destination file
503 | function db_upload_file
504 | {
505 | local FILE_SRC=$(normalize_path "$1")
506 | local FILE_DST=$(normalize_path "$2")
507 |
508 | shopt -s nocasematch
509 |
510 | #Checking not allowed file names
511 | basefile_dst=$(basename "$FILE_DST")
512 | if [[ $basefile_dst == "thumbs.db" || \
513 | $basefile_dst == "desktop.ini" || \
514 | $basefile_dst == ".ds_store" || \
515 | $basefile_dst == "icon\r" || \
516 | $basefile_dst == ".dropbox" || \
517 | $basefile_dst == ".dropbox.attr" \
518 | ]]; then
519 | print " > Skipping not allowed file name \"$FILE_DST\"\n"
520 | return
521 | fi
522 |
523 | shopt -u nocasematch
524 |
525 | #Checking file size
526 | FILE_SIZE=$(file_size "$FILE_SRC")
527 |
528 | #Checking if the file already exists
529 | TYPE=$(db_stat "$FILE_DST")
530 | if [[ $TYPE != "ERR" && $SKIP_EXISTING_FILES == 1 ]]; then
531 | print " > Skipping already existing file \"$FILE_DST\"\n"
532 | return
533 | fi
534 |
535 | # Checking if the file has the correct check sum
536 | if [[ $TYPE != "ERR" ]]; then
537 | sha_src=$(db_sha_local "$FILE_SRC")
538 | sha_dst=$(db_sha "$FILE_DST")
539 | if [[ $sha_src == $sha_dst && $sha_src != "ERR" ]]; then
540 | print "> Skipping file \"$FILE_SRC\", file exists with the same hash\n"
541 | return
542 | fi
543 | fi
544 |
545 | if [[ $FILE_SIZE -gt 157286000 ]]; then
546 | #If the file is greater than 150Mb, the chunked_upload API will be used
547 | db_chunked_upload_file "$FILE_SRC" "$FILE_DST"
548 | else
549 | db_simple_upload_file "$FILE_SRC" "$FILE_DST"
550 | fi
551 |
552 | }
553 |
554 | #Simple file upload
555 | #$1 = Local source file
556 | #$2 = Remote destination file
557 | function db_simple_upload_file
558 | {
559 | local FILE_SRC=$(normalize_path "$1")
560 | local FILE_DST=$(normalize_path "$2")
561 |
562 | if [[ $SHOW_PROGRESSBAR == 1 && $QUIET == 0 ]]; then
563 | CURL_PARAMETERS="--progress-bar"
564 | LINE_CR="\n"
565 | else
566 | CURL_PARAMETERS="-L -s"
567 | LINE_CR=""
568 | fi
569 |
570 | print " > Uploading \"$FILE_SRC\" to \"$FILE_DST\"... $LINE_CR"
571 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES $CURL_PARAMETERS -X POST -i --globoff -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Dropbox-API-Arg: {\"path\": \"$FILE_DST\",\"mode\": \"overwrite\",\"autorename\": true,\"mute\": false}" --header "Content-Type: application/octet-stream" --data-binary @"$FILE_SRC" "$API_UPLOAD_URL"
572 | check_http_response
573 |
574 | #Check
575 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
576 | print "DONE\n"
577 | else
578 | print "FAILED\n"
579 | print "An error occurred requesting /upload\n"
580 | ERROR_STATUS=1
581 | fi
582 | }
583 |
584 | #Chunked file upload
585 | #$1 = Local source file
586 | #$2 = Remote destination file
587 | function db_chunked_upload_file
588 | {
589 | local FILE_SRC=$(normalize_path "$1")
590 | local FILE_DST=$(normalize_path "$2")
591 |
592 |
593 | if [[ $SHOW_PROGRESSBAR == 1 && $QUIET == 0 ]]; then
594 | VERBOSE=1
595 | CURL_PARAMETERS="--progress-bar"
596 | else
597 | VERBOSE=0
598 | CURL_PARAMETERS="-L -s"
599 | fi
600 |
601 |
602 |
603 | local FILE_SIZE=$(file_size "$FILE_SRC")
604 | local OFFSET=0
605 | local UPLOAD_ID=""
606 | local UPLOAD_ERROR=0
607 | local CHUNK_PARAMS=""
608 |
609 | ## Ceil division
610 | let NUMBEROFCHUNK=($FILE_SIZE/1024/1024+$CHUNK_SIZE-1)/$CHUNK_SIZE
611 |
612 | if [[ $VERBOSE == 1 ]]; then
613 | print " > Uploading \"$FILE_SRC\" to \"$FILE_DST\" by $NUMBEROFCHUNK chunks ...\n"
614 | else
615 | print " > Uploading \"$FILE_SRC\" to \"$FILE_DST\" by $NUMBEROFCHUNK chunks "
616 | fi
617 |
618 | #Starting a new upload session
619 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Dropbox-API-Arg: {\"close\": false}" --header "Content-Type: application/octet-stream" --data-binary @/dev/null "$API_CHUNKED_UPLOAD_START_URL" 2> /dev/null
620 | check_http_response
621 |
622 | SESSION_ID=$(sed -n 's/{"session_id": *"*\([^"]*\)"*.*/\1/p' "$RESPONSE_FILE")
623 |
624 | chunkNumber=1
625 | #Uploading chunks...
626 | while ([[ $OFFSET != "$FILE_SIZE" ]]); do
627 |
628 | let OFFSET_MB=$OFFSET/1024/1024
629 |
630 | #Create the chunk
631 | dd if="$FILE_SRC" of="$CHUNK_FILE" bs=1048576 skip=$OFFSET_MB count=$CHUNK_SIZE 2> /dev/null
632 | local CHUNK_REAL_SIZE=$(file_size "$CHUNK_FILE")
633 |
634 | if [[ $VERBOSE == 1 ]]; then
635 | print " >> Uploading chunk $chunkNumber of $NUMBEROFCHUNK\n"
636 | fi
637 |
638 | #Uploading the chunk...
639 | echo > "$RESPONSE_FILE"
640 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST $CURL_PARAMETERS --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Dropbox-API-Arg: {\"cursor\": {\"session_id\": \"$SESSION_ID\",\"offset\": $OFFSET},\"close\": false}" --header "Content-Type: application/octet-stream" --data-binary @"$CHUNK_FILE" "$API_CHUNKED_UPLOAD_APPEND_URL"
641 | #check_http_response not needed, because we have to retry the request in case of error
642 |
643 | #Check
644 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
645 | let OFFSET=$OFFSET+$CHUNK_REAL_SIZE
646 | UPLOAD_ERROR=0
647 | if [[ $VERBOSE != 1 ]]; then
648 | print "."
649 | fi
650 | ((chunkNumber=chunkNumber+1))
651 | else
652 | if [[ $VERBOSE != 1 ]]; then
653 | print "*"
654 | fi
655 | let UPLOAD_ERROR=$UPLOAD_ERROR+1
656 |
657 | #On error, the upload is retried for max 3 times
658 | if [[ $UPLOAD_ERROR -gt 2 ]]; then
659 | print " FAILED\n"
660 | print "An error occurred requesting /chunked_upload\n"
661 | ERROR_STATUS=1
662 | return
663 | fi
664 | fi
665 |
666 | done
667 |
668 | UPLOAD_ERROR=0
669 |
670 | #Commit the upload
671 | while (true); do
672 |
673 | echo > "$RESPONSE_FILE"
674 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Dropbox-API-Arg: {\"cursor\": {\"session_id\": \"$SESSION_ID\",\"offset\": $OFFSET},\"commit\": {\"path\": \"$FILE_DST\",\"mode\": \"overwrite\",\"autorename\": true,\"mute\": false}}" --header "Content-Type: application/octet-stream" --data-binary @/dev/null "$API_CHUNKED_UPLOAD_FINISH_URL" 2> /dev/null
675 | #check_http_response not needed, because we have to retry the request in case of error
676 |
677 | #Check
678 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
679 | UPLOAD_ERROR=0
680 | break
681 | else
682 | print "*"
683 | let UPLOAD_ERROR=$UPLOAD_ERROR+1
684 |
685 | #On error, the commit is retried for max 3 times
686 | if [[ $UPLOAD_ERROR -gt 2 ]]; then
687 | print " FAILED\n"
688 | print "An error occurred requesting /commit_chunked_upload\n"
689 | ERROR_STATUS=1
690 | return
691 | fi
692 | fi
693 |
694 | done
695 |
696 | print " DONE\n"
697 | }
698 |
699 | #Directory upload
700 | #$1 = Local source dir
701 | #$2 = Remote destination dir
702 | function db_upload_dir
703 | {
704 | local DIR_SRC=$(normalize_path "$1")
705 | local DIR_DST=$(normalize_path "$2")
706 |
707 | #Creatig remote directory
708 | db_mkdir "$DIR_DST"
709 |
710 | for file in "$DIR_SRC/"*; do
711 | db_upload "$file" "$DIR_DST"
712 | done
713 | }
714 |
715 | #Generic download wrapper
716 | #$1 = Remote source file/dir
717 | #$2 = Local destination file/dir
718 | function db_download
719 | {
720 | local SRC=$(normalize_path "$1")
721 | local DST=$(normalize_path "$2")
722 |
723 | TYPE=$(db_stat "$SRC")
724 |
725 | #It's a directory
726 | if [[ $TYPE == "DIR" ]]; then
727 |
728 | #If the DST folder is not specified, I assume that is the current directory
729 | if [[ $DST == "" ]]; then
730 | DST="."
731 | fi
732 |
733 | #Checking if the destination directory exists
734 | if [[ ! -d $DST ]]; then
735 | local basedir=""
736 | else
737 | local basedir=$(basename "$SRC")
738 | fi
739 |
740 | local DEST_DIR=$(normalize_path "$DST/$basedir")
741 | print " > Downloading folder \"$SRC\" to \"$DEST_DIR\"... \n"
742 |
743 | if [[ ! -d "$DEST_DIR" ]]; then
744 | print " > Creating local directory \"$DEST_DIR\"... "
745 | mkdir -p "$DEST_DIR"
746 |
747 | #Check
748 | if [[ $? == 0 ]]; then
749 | print "DONE\n"
750 | else
751 | print "FAILED\n"
752 | ERROR_STATUS=1
753 | return
754 | fi
755 | fi
756 |
757 | if [[ $SRC == "/" ]]; then
758 | SRC_REQ=""
759 | else
760 | SRC_REQ="$SRC"
761 | fi
762 |
763 | OUT_FILE=$(db_list_outfile "$SRC_REQ")
764 | if [ $? -ne 0 ]; then
765 | # When db_list_outfile fail, the error message is OUT_FILE
766 | print "$OUT_FILE\n"
767 | ERROR_STATUS=1
768 | return
769 | fi
770 |
771 | #For each entry...
772 | while read -r line; do
773 |
774 | local FILE=${line%:*}
775 | local META=${line##*:}
776 | local TYPE=${META%;*}
777 | local SIZE=${META#*;}
778 |
779 | #Removing unneeded /
780 | FILE=${FILE##*/}
781 |
782 | if [[ $TYPE == "file" ]]; then
783 | db_download_file "$SRC/$FILE" "$DEST_DIR/$FILE"
784 | elif [[ $TYPE == "folder" ]]; then
785 | db_download "$SRC/$FILE" "$DEST_DIR"
786 | fi
787 |
788 | done < $OUT_FILE
789 |
790 | rm -fr $OUT_FILE
791 |
792 | #It's a file
793 | elif [[ $TYPE == "FILE" ]]; then
794 |
795 | #Checking DST
796 | if [[ $DST == "" ]]; then
797 | DST=$(basename "$SRC")
798 | fi
799 |
800 | #If the destination is a directory, the file will be download into
801 | if [[ -d $DST ]]; then
802 | DST="$DST/$SRC"
803 | fi
804 |
805 | db_download_file "$SRC" "$DST"
806 |
807 | #Doesn't exists
808 | else
809 | print " > No such file or directory: $SRC\n"
810 | ERROR_STATUS=1
811 | return
812 | fi
813 | }
814 |
815 | #Simple file download
816 | #$1 = Remote source file
817 | #$2 = Local destination file
818 | function db_download_file
819 | {
820 | local FILE_SRC=$(normalize_path "$1")
821 | local FILE_DST=$(normalize_path "$2")
822 |
823 | if [[ $SHOW_PROGRESSBAR == 1 && $QUIET == 0 ]]; then
824 | CURL_PARAMETERS="-L --progress-bar"
825 | LINE_CR="\n"
826 | else
827 | CURL_PARAMETERS="-L -s"
828 | LINE_CR=""
829 | fi
830 |
831 | #Checking if the file already exists
832 | if [[ -e $FILE_DST && $SKIP_EXISTING_FILES == 1 ]]; then
833 | print " > Skipping already existing file \"$FILE_DST\"\n"
834 | return
835 | fi
836 |
837 | # Checking if the file has the correct check sum
838 | if [[ $TYPE != "ERR" ]]; then
839 | sha_src=$(db_sha "$FILE_SRC")
840 | sha_dst=$(db_sha_local "$FILE_DST")
841 | if [[ $sha_src == $sha_dst && $sha_src != "ERR" ]]; then
842 | print "> Skipping file \"$FILE_SRC\", file exists with the same hash\n"
843 | return
844 | fi
845 | fi
846 |
847 | #Creating the empty file, that for two reasons:
848 | #1) In this way I can check if the destination file is writable or not
849 | #2) Curl doesn't automatically creates files with 0 bytes size
850 | dd if=/dev/zero of="$FILE_DST" count=0 2> /dev/null
851 | if [[ $? != 0 ]]; then
852 | print " > Error writing file $FILE_DST: permission denied\n"
853 | ERROR_STATUS=1
854 | return
855 | fi
856 |
857 | print " > Downloading \"$FILE_SRC\" to \"$FILE_DST\"... $LINE_CR"
858 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES $CURL_PARAMETERS -X POST --globoff -D "$RESPONSE_FILE" -o "$FILE_DST" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Dropbox-API-Arg: {\"path\": \"$FILE_SRC\"}" "$API_DOWNLOAD_URL"
859 | check_http_response
860 |
861 | #Check
862 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
863 | print "DONE\n"
864 | else
865 | print "FAILED\n"
866 | rm -fr "$FILE_DST"
867 | ERROR_STATUS=1
868 | return
869 | fi
870 | }
871 |
872 | #Saveurl
873 | #$1 = URL
874 | #$2 = Remote file destination
875 | function db_saveurl
876 | {
877 | local URL="$1"
878 | local FILE_DST=$(normalize_path "$2")
879 | local FILE_NAME=$(basename "$URL")
880 |
881 | print " > Downloading \"$URL\" to \"$FILE_DST\"..."
882 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"path\": \"$FILE_DST/$FILE_NAME\", \"url\": \"$URL\"}" "$API_SAVEURL_URL" 2> /dev/null
883 | check_http_response
884 |
885 | JOB_ID=$(sed -n 's/.*"async_job_id": *"*\([^"]*\)"*.*/\1/p' "$RESPONSE_FILE")
886 | if [[ $JOB_ID == "" ]]; then
887 | print " > Error getting the job id\n"
888 | return
889 | fi
890 |
891 | #Checking the status
892 | while (true); do
893 |
894 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"async_job_id\": \"$JOB_ID\"}" "$API_SAVEURL_JOBSTATUS_URL" 2> /dev/null
895 | check_http_response
896 |
897 | STATUS=$(sed -n 's/{".tag": *"*\([^"]*\)"*.*/\1/p' "$RESPONSE_FILE")
898 | case $STATUS in
899 |
900 | in_progress)
901 | print "+"
902 | ;;
903 |
904 | complete)
905 | print " DONE\n"
906 | break
907 | ;;
908 |
909 | failed)
910 | print " ERROR\n"
911 | MESSAGE=$(sed -n 's/.*"error_summary": *"*\([^"]*\)"*.*/\1/p' "$RESPONSE_FILE")
912 | print " > Error: $MESSAGE\n"
913 | break
914 | ;;
915 |
916 | esac
917 |
918 | sleep 2
919 |
920 | done
921 | }
922 |
923 | #Prints account info
924 | function db_account_info
925 | {
926 | print "Dropbox Uploader v$VERSION\n\n"
927 | print " > Getting info... "
928 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" "$API_ACCOUNT_INFO_URL" 2> /dev/null
929 | check_http_response
930 |
931 | #Check
932 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
933 |
934 | name=$(sed -n 's/.*"display_name": "\([^"]*\).*/\1/p' "$RESPONSE_FILE")
935 | echo -e "\n\nName:\t\t$name"
936 |
937 | uid=$(sed -n 's/.*"account_id": "\([^"]*\).*/\1/p' "$RESPONSE_FILE")
938 | echo -e "UID:\t\t$uid"
939 |
940 | email=$(sed -n 's/.*"email": "\([^"]*\).*/\1/p' "$RESPONSE_FILE")
941 | echo -e "Email:\t\t$email"
942 |
943 | country=$(sed -n 's/.*"country": "\([^"]*\).*/\1/p' "$RESPONSE_FILE")
944 | echo -e "Country:\t$country"
945 |
946 | echo ""
947 |
948 | else
949 | print "FAILED\n"
950 | ERROR_STATUS=1
951 | fi
952 | }
953 |
954 | #Prints account space usage info
955 | function db_account_space
956 | {
957 | print "Dropbox Uploader v$VERSION\n\n"
958 | print " > Getting space usage info... "
959 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" "$API_ACCOUNT_SPACE_URL" 2> /dev/null
960 | check_http_response
961 |
962 | #Check
963 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
964 |
965 | quota=$(sed -n 's/.*"allocated": \([0-9]*\).*/\1/p' "$RESPONSE_FILE")
966 | let quota_mb=$quota/1024/1024
967 | echo -e "\n\nQuota:\t$quota_mb Mb"
968 |
969 | used=$(sed -n 's/.*"used": \([0-9]*\).*/\1/p' "$RESPONSE_FILE")
970 | let used_mb=$used/1024/1024
971 | echo -e "Used:\t$used_mb Mb"
972 |
973 | let free_mb=$((quota-used))/1024/1024
974 | echo -e "Free:\t$free_mb Mb"
975 |
976 | echo ""
977 |
978 | else
979 | print "FAILED\n"
980 | ERROR_STATUS=1
981 | fi
982 | }
983 |
984 | #Account unlink
985 | function db_unlink
986 | {
987 | echo -ne "Are you sure you want unlink this script from your Dropbox account? [y/n]"
988 | read -r answer
989 | if [[ $answer == "y" ]]; then
990 | rm -fr "$CONFIG_FILE"
991 | echo -ne "DONE\n"
992 | fi
993 | }
994 |
995 | #Delete a remote file
996 | #$1 = Remote file to delete
997 | function db_delete
998 | {
999 | local FILE_DST=$(normalize_path "$1")
1000 |
1001 | print " > Deleting \"$FILE_DST\"... "
1002 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"path\": \"$FILE_DST\"}" "$API_DELETE_URL" 2> /dev/null
1003 | check_http_response
1004 |
1005 | #Check
1006 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
1007 | print "DONE\n"
1008 | else
1009 | print "FAILED\n"
1010 | ERROR_STATUS=1
1011 | fi
1012 | }
1013 |
1014 | #Move/Rename a remote file
1015 | #$1 = Remote file to rename or move
1016 | #$2 = New file name or location
1017 | function db_move
1018 | {
1019 | local FILE_SRC=$(normalize_path "$1")
1020 | local FILE_DST=$(normalize_path "$2")
1021 |
1022 | TYPE=$(db_stat "$FILE_DST")
1023 |
1024 | #If the destination it's a directory, the source will be moved into it
1025 | if [[ $TYPE == "DIR" ]]; then
1026 | local filename=$(basename "$FILE_SRC")
1027 | FILE_DST=$(normalize_path "$FILE_DST/$filename")
1028 | fi
1029 |
1030 | print " > Moving \"$FILE_SRC\" to \"$FILE_DST\" ... "
1031 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"from_path\": \"$FILE_SRC\", \"to_path\": \"$FILE_DST\"}" "$API_MOVE_URL" 2> /dev/null
1032 | check_http_response
1033 |
1034 | #Check
1035 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
1036 | print "DONE\n"
1037 | else
1038 | print "FAILED\n"
1039 | ERROR_STATUS=1
1040 | fi
1041 | }
1042 |
1043 | #Copy a remote file to a remote location
1044 | #$1 = Remote file to rename or move
1045 | #$2 = New file name or location
1046 | function db_copy
1047 | {
1048 | local FILE_SRC=$(normalize_path "$1")
1049 | local FILE_DST=$(normalize_path "$2")
1050 |
1051 | TYPE=$(db_stat "$FILE_DST")
1052 |
1053 | #If the destination it's a directory, the source will be copied into it
1054 | if [[ $TYPE == "DIR" ]]; then
1055 | local filename=$(basename "$FILE_SRC")
1056 | FILE_DST=$(normalize_path "$FILE_DST/$filename")
1057 | fi
1058 |
1059 | print " > Copying \"$FILE_SRC\" to \"$FILE_DST\" ... "
1060 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"from_path\": \"$FILE_SRC\", \"to_path\": \"$FILE_DST\"}" "$API_COPY_URL" 2> /dev/null
1061 | check_http_response
1062 |
1063 | #Check
1064 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
1065 | print "DONE\n"
1066 | else
1067 | print "FAILED\n"
1068 | ERROR_STATUS=1
1069 | fi
1070 | }
1071 |
1072 | #Create a new directory
1073 | #$1 = Remote directory to create
1074 | function db_mkdir
1075 | {
1076 | local DIR_DST=$(normalize_path "$1")
1077 |
1078 | print " > Creating Directory \"$DIR_DST\"... "
1079 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"path\": \"$DIR_DST\"}" "$API_MKDIR_URL" 2> /dev/null
1080 | check_http_response
1081 |
1082 | #Check
1083 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
1084 | print "DONE\n"
1085 | elif grep -q "^HTTP/[12].* 403" "$RESPONSE_FILE"; then
1086 | print "ALREADY EXISTS\n"
1087 | else
1088 | print "FAILED\n"
1089 | ERROR_STATUS=1
1090 | fi
1091 | }
1092 |
1093 | #List a remote folder and returns the path to the file containing the output
1094 | #$1 = Remote directory
1095 | #$2 = Cursor (Optional)
1096 | function db_list_outfile
1097 | {
1098 |
1099 | local DIR_DST="$1"
1100 | local HAS_MORE="false"
1101 | local CURSOR=""
1102 |
1103 | if [[ -n "$2" ]]; then
1104 | CURSOR="$2"
1105 | HAS_MORE="true"
1106 | fi
1107 |
1108 | OUT_FILE="$TMP_DIR/du_tmp_out_$RANDOM"
1109 |
1110 | while (true); do
1111 |
1112 | if [[ $HAS_MORE == "true" ]]; then
1113 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"cursor\": \"$CURSOR\"}" "$API_LIST_FOLDER_CONTINUE_URL" 2> /dev/null
1114 | else
1115 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"path\": \"$DIR_DST\",\"include_media_info\": false,\"include_deleted\": false,\"include_has_explicit_shared_members\": false}" "$API_LIST_FOLDER_URL" 2> /dev/null
1116 | fi
1117 |
1118 | check_http_response
1119 |
1120 | HAS_MORE=$(sed -n 's/.*"has_more": *\([a-z]*\).*/\1/p' "$RESPONSE_FILE")
1121 | CURSOR=$(sed -n 's/.*"cursor": *"\([^"]*\)".*/\1/p' "$RESPONSE_FILE")
1122 |
1123 | #Check
1124 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
1125 |
1126 | #Extracting directory content [...]
1127 | #and replacing "}, {" with "}\n{"
1128 | #I don't like this piece of code... but seems to be the only way to do this with SED, writing a portable code...
1129 | local DIR_CONTENT=$(sed -n 's/.*: \[{\(.*\)/\1/p' "$RESPONSE_FILE" | sed 's/}, *{/}\
1130 | {/g')
1131 |
1132 | #Converting escaped quotes to unicode format
1133 | echo "$DIR_CONTENT" | sed 's/\\"/\\u0022/' > "$TEMP_FILE"
1134 |
1135 | #Extracting files and subfolders
1136 | while read -r line; do
1137 |
1138 | local FILE=$(echo "$line" | sed -n 's/.*"path_display": *"\([^"]*\)".*/\1/p')
1139 | local TYPE=$(echo "$line" | sed -n 's/.*".tag": *"\([^"]*\).*/\1/p')
1140 | local SIZE=$(convert_bytes $(echo "$line" | sed -n 's/.*"size": *\([0-9]*\).*/\1/p'))
1141 |
1142 | echo -e "$FILE:$TYPE;$SIZE" >> "$OUT_FILE"
1143 |
1144 | done < "$TEMP_FILE"
1145 |
1146 | if [[ $HAS_MORE == "false" ]]; then
1147 | break
1148 | fi
1149 |
1150 | else
1151 | return
1152 | fi
1153 |
1154 | done
1155 |
1156 | echo $OUT_FILE
1157 | }
1158 |
1159 | #List remote directory
1160 | #$1 = Remote directory
1161 | function db_list
1162 | {
1163 | local DIR_DST=$(normalize_path "$1")
1164 |
1165 | print " > Listing \"$DIR_DST\"... "
1166 |
1167 | if [[ "$DIR_DST" == "/" ]]; then
1168 | DIR_DST=""
1169 | fi
1170 |
1171 | OUT_FILE=$(db_list_outfile "$DIR_DST")
1172 | if [ -z "$OUT_FILE" ]; then
1173 | print "FAILED\n"
1174 | ERROR_STATUS=1
1175 | return
1176 | else
1177 | print "DONE\n"
1178 | fi
1179 |
1180 | #Looking for the biggest file size
1181 | #to calculate the padding to use
1182 | local padding=0
1183 | while read -r line; do
1184 | local FILE=${line%:*}
1185 | local META=${line##*:}
1186 | local SIZE=${META#*;}
1187 |
1188 | if [[ ${#SIZE} -gt $padding ]]; then
1189 | padding=${#SIZE}
1190 | fi
1191 | done < "$OUT_FILE"
1192 |
1193 | #For each entry, printing directories...
1194 | while read -r line; do
1195 |
1196 | local FILE=${line%:*}
1197 | local META=${line##*:}
1198 | local TYPE=${META%;*}
1199 | local SIZE=${META#*;}
1200 |
1201 | #Removing unneeded /
1202 | FILE=${FILE##*/}
1203 |
1204 | if [[ $TYPE == "folder" ]]; then
1205 | FILE=$(echo -e "$FILE")
1206 | $PRINTF " [D] %-${padding}s %s\n" "$SIZE" "$FILE"
1207 | fi
1208 |
1209 | done < "$OUT_FILE"
1210 |
1211 | #For each entry, printing files...
1212 | while read -r line; do
1213 |
1214 | local FILE=${line%:*}
1215 | local META=${line##*:}
1216 | local TYPE=${META%;*}
1217 | local SIZE=${META#*;}
1218 |
1219 | #Removing unneeded /
1220 | FILE=${FILE##*/}
1221 |
1222 | if [[ $TYPE == "file" ]]; then
1223 | FILE=$(echo -e "$FILE")
1224 | $PRINTF " [F] %-${padding}s %s\n" "$SIZE" "$FILE"
1225 | fi
1226 |
1227 | done < "$OUT_FILE"
1228 |
1229 | rm -fr "$OUT_FILE"
1230 | }
1231 |
1232 | #Longpoll remote directory only once
1233 | #$1 = Timeout
1234 | #$2 = Remote directory
1235 | function db_monitor_nonblock
1236 | {
1237 | local TIMEOUT=$1
1238 | local DIR_DST=$(normalize_path "$2")
1239 |
1240 | if [[ "$DIR_DST" == "/" ]]; then
1241 | DIR_DST=""
1242 | fi
1243 |
1244 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"path\": \"$DIR_DST\",\"include_media_info\": false,\"include_deleted\": false,\"include_has_explicit_shared_members\": false}" "$API_LIST_FOLDER_URL" 2> /dev/null
1245 | check_http_response
1246 |
1247 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
1248 |
1249 | local CURSOR=$(sed -n 's/.*"cursor": *"\([^"]*\)".*/\1/p' "$RESPONSE_FILE")
1250 |
1251 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Content-Type: application/json" --data "{\"cursor\": \"$CURSOR\",\"timeout\": ${TIMEOUT}}" "$API_LONGPOLL_FOLDER" 2> /dev/null
1252 | check_http_response
1253 |
1254 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
1255 | local CHANGES=$(sed -n 's/.*"changes" *: *\([a-z]*\).*/\1/p' "$RESPONSE_FILE")
1256 | else
1257 | ERROR_MSG=$(grep "Error in call" "$RESPONSE_FILE")
1258 | print "FAILED to longpoll (http error): $ERROR_MSG\n"
1259 | ERROR_STATUS=1
1260 | return 1
1261 | fi
1262 |
1263 | if [[ -z "$CHANGES" ]]; then
1264 | print "FAILED to longpoll (unexpected response)\n"
1265 | ERROR_STATUS=1
1266 | return 1
1267 | fi
1268 |
1269 | if [ "$CHANGES" == "true" ]; then
1270 |
1271 | OUT_FILE=$(db_list_outfile "$DIR_DST" "$CURSOR")
1272 |
1273 | if [ -z "$OUT_FILE" ]; then
1274 | print "FAILED to list changes\n"
1275 | ERROR_STATUS=1
1276 | return
1277 | fi
1278 |
1279 | #For each entry, printing directories...
1280 | while read -r line; do
1281 |
1282 | local FILE=${line%:*}
1283 | local META=${line##*:}
1284 | local TYPE=${META%;*}
1285 | local SIZE=${META#*;}
1286 |
1287 | #Removing unneeded /
1288 | FILE=${FILE##*/}
1289 |
1290 | if [[ $TYPE == "folder" ]]; then
1291 | FILE=$(echo -e "$FILE")
1292 | $PRINTF " [D] %s\n" "$FILE"
1293 | elif [[ $TYPE == "file" ]]; then
1294 | FILE=$(echo -e "$FILE")
1295 | $PRINTF " [F] %s %s\n" "$SIZE" "$FILE"
1296 | elif [[ $TYPE == "deleted" ]]; then
1297 | FILE=$(echo -e "$FILE")
1298 | $PRINTF " [-] %s\n" "$FILE"
1299 | fi
1300 |
1301 | done < "$OUT_FILE"
1302 |
1303 | rm -fr "$OUT_FILE"
1304 | fi
1305 |
1306 | else
1307 | ERROR_STATUS=1
1308 | return 1
1309 | fi
1310 |
1311 | }
1312 |
1313 | #Longpoll continuously remote directory
1314 | #$1 = Timeout
1315 | #$2 = Remote directory
1316 | function db_monitor
1317 | {
1318 | local TIMEOUT=$1
1319 | local DIR_DST=$(normalize_path "$2")
1320 |
1321 | while (true); do
1322 | db_monitor_nonblock "$TIMEOUT" "$2"
1323 | done
1324 | }
1325 |
1326 | #Share remote file
1327 | #$1 = Remote file
1328 | function db_share
1329 | {
1330 | local FILE_DST=$(normalize_path "$1")
1331 |
1332 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"path\": \"$FILE_DST\",\"settings\": {\"requested_visibility\": \"public\"}}" "$API_SHARE_URL" 2> /dev/null
1333 | check_http_response
1334 |
1335 | #Check
1336 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
1337 | print " > Share link: "
1338 | SHARE_LINK=$(sed -n 's/.*"url": "\([^"]*\).*/\1/p' "$RESPONSE_FILE")
1339 | echo "$SHARE_LINK"
1340 | else
1341 | get_Share "$FILE_DST"
1342 | fi
1343 | }
1344 |
1345 | #Query existing shared link
1346 | #$1 = Remote file
1347 | function get_Share
1348 | {
1349 | local FILE_DST=$(normalize_path "$1")
1350 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"path\": \"$FILE_DST\",\"direct_only\": true}" "$API_SHARE_LIST"
1351 | check_http_response
1352 |
1353 | #Check
1354 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
1355 | print " > Share link: "
1356 | SHARE_LINK=$(sed -n 's/.*"url": "\([^"]*\).*/\1/p' "$RESPONSE_FILE")
1357 | echo "$SHARE_LINK"
1358 | else
1359 | print "FAILED\n"
1360 | MESSAGE=$(sed -n 's/.*"error_summary": *"*\([^"]*\)"*.*/\1/p' "$RESPONSE_FILE")
1361 | print " > Error: $MESSAGE\n"
1362 | ERROR_STATUS=1
1363 | fi
1364 | }
1365 |
1366 | #Search on Dropbox
1367 | #$1 = query
1368 | function db_search
1369 | {
1370 | local QUERY="$1"
1371 |
1372 | print " > Searching for \"$QUERY\"... "
1373 |
1374 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"path\": \"\",\"query\": \"$QUERY\",\"start\": 0,\"max_results\": 1000,\"mode\": \"filename\"}" "$API_SEARCH_URL" 2> /dev/null
1375 | check_http_response
1376 |
1377 | #Check
1378 | if grep -q "^HTTP/[12].* 200" "$RESPONSE_FILE"; then
1379 | print "DONE\n"
1380 | else
1381 | print "FAILED\n"
1382 | ERROR_STATUS=1
1383 | fi
1384 |
1385 | #Extracting directory content [...]
1386 | #and replacing "}, {" with "}\n{"
1387 | #I don't like this piece of code... but seems to be the only way to do this with SED, writing a portable code...
1388 | local DIR_CONTENT=$(sed 's/}, *{/}\
1389 | {/g' "$RESPONSE_FILE")
1390 |
1391 | #Converting escaped quotes to unicode format
1392 | echo "$DIR_CONTENT" | sed 's/\\"/\\u0022/' > "$TEMP_FILE"
1393 |
1394 | #Extracting files and subfolders
1395 | rm -fr "$RESPONSE_FILE"
1396 | while read -r line; do
1397 |
1398 | local FILE=$(echo "$line" | sed -n 's/.*"path_display": *"\([^"]*\)".*/\1/p')
1399 | local TYPE=$(echo "$line" | sed -n 's/.*".tag": *"\([^"]*\).*/\1/p')
1400 | local SIZE=$(convert_bytes $(echo "$line" | sed -n 's/.*"size": *\([0-9]*\).*/\1/p'))
1401 |
1402 | echo -e "$FILE:$TYPE;$SIZE" >> "$RESPONSE_FILE"
1403 |
1404 | done < "$TEMP_FILE"
1405 |
1406 | #Looking for the biggest file size
1407 | #to calculate the padding to use
1408 | local padding=0
1409 | while read -r line; do
1410 | local FILE=${line%:*}
1411 | local META=${line##*:}
1412 | local SIZE=${META#*;}
1413 |
1414 | if [[ ${#SIZE} -gt $padding ]]; then
1415 | padding=${#SIZE}
1416 | fi
1417 | done < "$RESPONSE_FILE"
1418 |
1419 | #For each entry, printing directories...
1420 | while read -r line; do
1421 |
1422 | local FILE=${line%:*}
1423 | local META=${line##*:}
1424 | local TYPE=${META%;*}
1425 | local SIZE=${META#*;}
1426 |
1427 | if [[ $TYPE == "folder" ]]; then
1428 | FILE=$(echo -e "$FILE")
1429 | $PRINTF " [D] %-${padding}s %s\n" "$SIZE" "$FILE"
1430 | fi
1431 |
1432 | done < "$RESPONSE_FILE"
1433 |
1434 | #For each entry, printing files...
1435 | while read -r line; do
1436 |
1437 | local FILE=${line%:*}
1438 | local META=${line##*:}
1439 | local TYPE=${META%;*}
1440 | local SIZE=${META#*;}
1441 |
1442 | if [[ $TYPE == "file" ]]; then
1443 | FILE=$(echo -e "$FILE")
1444 | $PRINTF " [F] %-${padding}s %s\n" "$SIZE" "$FILE"
1445 | fi
1446 |
1447 | done < "$RESPONSE_FILE"
1448 |
1449 | }
1450 |
1451 | #Query the sha256-dropbox-sum of a remote file
1452 | #see https://www.dropbox.com/developers/reference/content-hash for more information
1453 | #$1 = Remote file
1454 | function db_sha
1455 | {
1456 | local FILE=$(normalize_path "$1")
1457 |
1458 | if [[ $FILE == "/" ]]; then
1459 | echo "ERR"
1460 | return
1461 | fi
1462 |
1463 | #Checking if it's a file or a directory and get the sha-sum
1464 | $CURL_BIN $CURL_ACCEPT_CERTIFICATES -X POST -L -s --show-error --globoff -i -o "$RESPONSE_FILE" --header "Authorization: Bearer $OAUTH_ACCESS_TOKEN" --header "Content-Type: application/json" --data "{\"path\": \"$FILE\"}" "$API_METADATA_URL" 2> /dev/null
1465 | check_http_response
1466 |
1467 | local TYPE=$(sed -n 's/{".tag": *"*\([^"]*\)"*.*/\1/p' "$RESPONSE_FILE")
1468 | if [[ $TYPE == "folder" ]]; then
1469 | echo "ERR"
1470 | return
1471 | fi
1472 |
1473 | local SHA256=$(sed -n 's/.*"content_hash": "\([^"]*\).*/\1/p' "$RESPONSE_FILE")
1474 | echo "$SHA256"
1475 | }
1476 |
1477 | #Query the sha256-dropbox-sum of a local file
1478 | #see https://www.dropbox.com/developers/reference/content-hash for more information
1479 | #$1 = Local file
1480 | function db_sha_local
1481 | {
1482 | local FILE=$(normalize_path "$1")
1483 | local FILE_SIZE=$(file_size "$FILE")
1484 | local OFFSET=0
1485 | local SKIP=0
1486 | local SHA_CONCAT=""
1487 |
1488 | which shasum > /dev/null
1489 | if [[ $? != 0 ]]; then
1490 | echo "ERR"
1491 | return
1492 | fi
1493 |
1494 | while ([[ $OFFSET -lt "$FILE_SIZE" ]]); do
1495 | dd if="$FILE" of="$CHUNK_FILE" bs=4194304 skip=$SKIP count=1 2> /dev/null
1496 | local SHA=$(shasum -a 256 "$CHUNK_FILE" | awk '{print $1}')
1497 | SHA_CONCAT="${SHA_CONCAT}${SHA}"
1498 |
1499 | let OFFSET=$OFFSET+4194304
1500 | let SKIP=$SKIP+1
1501 | done
1502 |
1503 | shaHex=$(echo $SHA_CONCAT | sed 's/\([0-9A-F]\{2\}\)/\\x\1/gI')
1504 | echo -ne $shaHex | shasum -a 256 | awk '{print $1}'
1505 | }
1506 |
1507 | ################
1508 | #### SETUP ####
1509 | ################
1510 |
1511 | #CHECKING FOR AUTH FILE
1512 | if [[ -e $CONFIG_FILE ]]; then
1513 |
1514 | #Loading data... and change old format config if necesary.
1515 | source "$CONFIG_FILE" 2>/dev/null || {
1516 | sed -i'' 's/:/=/' "$CONFIG_FILE" && source "$CONFIG_FILE" 2>/dev/null
1517 | }
1518 |
1519 | #Checking if it's still a v1 API configuration file
1520 | if [[ $APPKEY != "" || $APPSECRET != "" ]]; then
1521 | echo -ne "The config file contains the old deprecated v1 oauth tokens.\n"
1522 | echo -ne "Please run again the script and follow the configuration wizard. The old configuration file has been backed up to $CONFIG_FILE.old\n"
1523 | mv "$CONFIG_FILE" "$CONFIG_FILE".old
1524 | exit 1
1525 | fi
1526 |
1527 | #Checking loaded data
1528 | if [[ $OAUTH_ACCESS_TOKEN = "" ]]; then
1529 | echo -ne "Error loading data from $CONFIG_FILE...\n"
1530 | echo -ne "It is recommended to run $0 unlink\n"
1531 | remove_temp_files
1532 | exit 1
1533 | fi
1534 |
1535 | #NEW SETUP...
1536 | else
1537 |
1538 | echo -ne "\n This is the first time you run this script, please follow the instructions:\n\n"
1539 | echo -ne " 1) Open the following URL in your Browser, and log in using your account: $APP_CREATE_URL\n"
1540 | echo -ne " 2) Click on \"Create App\", then select \"Dropbox API app\"\n"
1541 | echo -ne " 3) Now go on with the configuration, choosing the app permissions and access restrictions to your DropBox folder\n"
1542 | echo -ne " 4) Enter the \"App Name\" that you prefer (e.g. MyUploader$RANDOM$RANDOM$RANDOM)\n\n"
1543 |
1544 | echo -ne " Now, click on the \"Create App\" button.\n\n"
1545 |
1546 | echo -ne " When your new App is successfully created, please click on the Generate button\n"
1547 | echo -ne " under the 'Generated access token' section, then copy and paste the new access token here:\n\n"
1548 |
1549 | echo -ne " # Access token: "
1550 | read -r OAUTH_ACCESS_TOKEN
1551 |
1552 | echo -ne "\n > The access token is $OAUTH_ACCESS_TOKEN. Looks ok? [y/N]: "
1553 | read -r answer
1554 | if [[ $answer != "y" ]]; then
1555 | remove_temp_files
1556 | exit 1
1557 | fi
1558 |
1559 | echo "OAUTH_ACCESS_TOKEN=$OAUTH_ACCESS_TOKEN" > "$CONFIG_FILE"
1560 | echo " The configuration has been saved."
1561 |
1562 | remove_temp_files
1563 | exit 0
1564 | fi
1565 |
1566 | ################
1567 | #### START ####
1568 | ################
1569 |
1570 | COMMAND="${*:$OPTIND:1}"
1571 | ARG1="${*:$OPTIND+1:1}"
1572 | ARG2="${*:$OPTIND+2:1}"
1573 |
1574 | let argnum=$#-$OPTIND
1575 |
1576 | #CHECKING PARAMS VALUES
1577 | case $COMMAND in
1578 |
1579 | upload)
1580 |
1581 | if [[ $argnum -lt 2 ]]; then
1582 | usage
1583 | fi
1584 |
1585 | FILE_DST="${*:$#:1}"
1586 |
1587 | for (( i=OPTIND+1; i<$#; i++ )); do
1588 | FILE_SRC="${*:$i:1}"
1589 | db_upload "$FILE_SRC" "/$FILE_DST"
1590 | done
1591 |
1592 | ;;
1593 |
1594 | download)
1595 |
1596 | if [[ $argnum -lt 1 ]]; then
1597 | usage
1598 | fi
1599 |
1600 | FILE_SRC="$ARG1"
1601 | FILE_DST="$ARG2"
1602 |
1603 | db_download "/$FILE_SRC" "$FILE_DST"
1604 |
1605 | ;;
1606 |
1607 | saveurl)
1608 |
1609 | if [[ $argnum -lt 1 ]]; then
1610 | usage
1611 | fi
1612 |
1613 | URL=$ARG1
1614 | FILE_DST="$ARG2"
1615 |
1616 | db_saveurl "$URL" "/$FILE_DST"
1617 |
1618 | ;;
1619 |
1620 | share)
1621 |
1622 | if [[ $argnum -lt 1 ]]; then
1623 | usage
1624 | fi
1625 |
1626 | FILE_DST="$ARG1"
1627 |
1628 | db_share "/$FILE_DST"
1629 |
1630 | ;;
1631 |
1632 | info)
1633 |
1634 | db_account_info
1635 |
1636 | ;;
1637 |
1638 | space)
1639 |
1640 | db_account_space
1641 |
1642 | ;;
1643 |
1644 | delete|remove)
1645 |
1646 | if [[ $argnum -lt 1 ]]; then
1647 | usage
1648 | fi
1649 |
1650 | FILE_DST="$ARG1"
1651 |
1652 | db_delete "/$FILE_DST"
1653 |
1654 | ;;
1655 |
1656 | move|rename)
1657 |
1658 | if [[ $argnum -lt 2 ]]; then
1659 | usage
1660 | fi
1661 |
1662 | FILE_SRC="$ARG1"
1663 | FILE_DST="$ARG2"
1664 |
1665 | db_move "/$FILE_SRC" "/$FILE_DST"
1666 |
1667 | ;;
1668 |
1669 | copy)
1670 |
1671 | if [[ $argnum -lt 2 ]]; then
1672 | usage
1673 | fi
1674 |
1675 | FILE_SRC="$ARG1"
1676 | FILE_DST="$ARG2"
1677 |
1678 | db_copy "/$FILE_SRC" "/$FILE_DST"
1679 |
1680 | ;;
1681 |
1682 | mkdir)
1683 |
1684 | if [[ $argnum -lt 1 ]]; then
1685 | usage
1686 | fi
1687 |
1688 | DIR_DST="$ARG1"
1689 |
1690 | db_mkdir "/$DIR_DST"
1691 |
1692 | ;;
1693 |
1694 | search)
1695 |
1696 | if [[ $argnum -lt 1 ]]; then
1697 | usage
1698 | fi
1699 |
1700 | QUERY=$ARG1
1701 |
1702 | db_search "$QUERY"
1703 |
1704 | ;;
1705 |
1706 | list)
1707 |
1708 | DIR_DST="$ARG1"
1709 |
1710 | #Checking DIR_DST
1711 | if [[ $DIR_DST == "" ]]; then
1712 | DIR_DST="/"
1713 | fi
1714 |
1715 | db_list "/$DIR_DST"
1716 |
1717 | ;;
1718 |
1719 | monitor)
1720 |
1721 | DIR_DST="$ARG1"
1722 | TIMEOUT=$ARG2
1723 |
1724 | #Checking DIR_DST
1725 | if [[ $DIR_DST == "" ]]; then
1726 | DIR_DST="/"
1727 | fi
1728 |
1729 | print " > Monitoring \"$DIR_DST\" for changes...\n"
1730 |
1731 | if [[ -n $TIMEOUT ]]; then
1732 | db_monitor_nonblock $TIMEOUT "/$DIR_DST"
1733 | else
1734 | db_monitor 60 "/$DIR_DST"
1735 | fi
1736 |
1737 | ;;
1738 |
1739 | unlink)
1740 |
1741 | db_unlink
1742 |
1743 | ;;
1744 |
1745 | *)
1746 |
1747 | if [[ $COMMAND != "" ]]; then
1748 | print "Error: Unknown command: $COMMAND\n\n"
1749 | ERROR_STATUS=1
1750 | fi
1751 | usage
1752 |
1753 | ;;
1754 |
1755 | esac
1756 |
1757 | remove_temp_files
1758 |
1759 | if [[ $ERROR_STATUS -ne 0 ]]; then
1760 | echo "Some error occured. Please check the log."
1761 | fi
1762 |
1763 | exit $ERROR_STATUS
1764 |
--------------------------------------------------------------------------------
/g:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MannyHackers/ShellBot/d45db74b51eafec7cdf87ee4a7c51a0b032f820b/g
--------------------------------------------------------------------------------
/heroku.yml:
--------------------------------------------------------------------------------
1 | build:
2 | docker:
3 | worker: Dockerfile
4 | run:
5 | worker: node server
6 |
--------------------------------------------------------------------------------
/lib/command.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Attaches to a chat, spawns a pty, attaches it to the terminal emulator
3 | * and the renderer and manages them. Handles incoming commands & input,
4 | * and posts complimentary messages such as command itself and output code.
5 | **/
6 |
7 | var util = require("util");
8 | var escapeHtml = require("escape-html");
9 | var pty = require("node-pty");
10 | var termios = require("node-termios");
11 | var utils = require("./utils");
12 | var terminal = require("./terminal");
13 | var renderer = require("./renderer");
14 | var tsyms = termios.native.ALL_SYMBOLS;
15 |
16 | function Command(reply, context, command) {
17 | var toUser = reply.destination > 0;
18 |
19 | this.startTime = Date.now();
20 | this.reply = reply;
21 | this.command = command;
22 | this.pty = pty.spawn(context.shell, [context.interactive ? "-ic" : "-c", command], {
23 | cols: context.size.columns,
24 | rows: context.size.rows,
25 | cwd: context.cwd,
26 | env: context.env,
27 | });
28 | this.termios = new termios.Termios(this.pty._fd);
29 | this.termios.c_lflag &= ~(tsyms.ISIG | tsyms.IEXTEN);
30 | this.termios.c_lflag &= ~tsyms.ECHO; // disable ECHO
31 | this.termios.c_lflag |= tsyms.ICANON | tsyms.ECHONL; // we need it for /end, it needs to be active beforehand
32 | this.termios.c_iflag = (this.termios.c_iflag & ~(tsyms.INLCR | tsyms.IGNCR)) | tsyms.ICRNL; // CR to NL
33 | this.termios.writeTo(this.pty._fd);
34 |
35 | this.terminal = terminal.createTerminal({
36 | columns: context.size.columns,
37 | rows: context.size.rows,
38 | });
39 | this.state = this.terminal.state;
40 | this.renderer = new renderer.Renderer(reply, this.state, {
41 | cursorString: "\uD83D\uDD38",
42 | cursorBlinkString: "\uD83D\uDD38",
43 | hidePreview: !context.linkPreviews,
44 | unfinishedHidePreview: true,
45 | silent: context.silent,
46 | unfinishedSilent: true,
47 | maxLinesWait: toUser ? 20 : 30,
48 | maxLinesEmitted: 30,
49 | lineTime: toUser ? 400 : 1200,
50 | chunkTime: toUser ? 3000 : 6000,
51 | editTime: toUser ? 300 : 2500,
52 | unfinishedTime: toUser ? 1000 : 2000,
53 | startFill: "· ",
54 | });
55 | this._initKeypad();
56 | //FIXME: take additional steps to reduce messages sent to group. do typing actions count?
57 |
58 | // Post initial message
59 | this.initialMessage = new utils.EditedMessage(reply, this._renderInitial(), "HTML");
60 |
61 | // Process command output
62 | this.pty.on("data", this._ptyData.bind(this));
63 |
64 | // Handle command exit
65 | this.pty.on("exit", this._exit.bind(this));
66 | }
67 | util.inherits(Command, require("events").EventEmitter);
68 |
69 | Command.prototype._renderInitial = function _renderInitial() {
70 | var content = "", title = this.state.metas.title, badges = this.badges || "";
71 | if (title) {
72 | content += "" + escapeHtml(title) + "\n";
73 | content += badges + "$ " + escapeHtml(this.command);
74 | } else {
75 | content += badges + "$ " + escapeHtml(this.command) + "";
76 | }
77 | return content;
78 | }
79 |
80 | Command.prototype._ptyData = function _ptyData(chunk) {
81 | //FIXME: implement some backpressure, for example, read smaller chunks, stop reading if there are >= 20 lines waiting to be pushed, set the HWM
82 | if ((typeof chunk !== "string") && !(chunk instanceof String))
83 | throw new Error("Expected a String, you liar.");
84 | this.interacted = true;
85 | this.terminal.write(chunk, "utf-8", this._update.bind(this));
86 | };
87 |
88 | Command.prototype._update = function _update() {
89 | this.initialMessage.edit(this._renderInitial());
90 | this.renderer.update();
91 | };
92 |
93 | Command.prototype.resize = function resize(size) {
94 | this.interacted = true;
95 | this.metaActive = false;
96 | this.state.resize(size);
97 | this._update();
98 | this.pty.resize(size.columns, size.rows);
99 | };
100 |
101 | Command.prototype.redraw = function redraw() {
102 | this.interacted = true;
103 | this.metaActive = false;
104 | this.pty.redraw();
105 | };
106 |
107 | Command.prototype.sendSignal = function sendSignal(signal, group) {
108 | this.interacted = true;
109 | this.metaActive = false;
110 | var pid = this.pty.pid;
111 | if (group) pid = -pid;
112 | process.kill(pid, signal);
113 | };
114 |
115 | Command.prototype.sendEof = function sendEof() {
116 | this.interacted = true;
117 | this.metaActive = false;
118 |
119 | // I don't know how to cause a 'buffer flush to the app' (the effect of Control+D)
120 | // without actually pressing it into the console. So let's do just that.
121 | // TTY needs to be in ICANON mode from the start, enabling it now doesn't work
122 |
123 | // write EOF control character
124 | this.termios.loadFrom(this.pty._fd);
125 | this.pty.write(Buffer.from([ this.termios.c_cc[tsyms.VEOF] ]));
126 | };
127 |
128 | Command.prototype._exit = function _exit(code, signal) {
129 | this._update();
130 | this.renderer.flushUnfinished();
131 |
132 |
133 | //FIXME: could wait until all edits are made before posting exited message
134 | if ((Date.now() - this.startTime) < 2000 && !signal && code === 0 && !this.interacted) {
135 | // For short lived commands that completed without output, we simply add a tick to the original message
136 | this.badges = "\u2705 ";
137 | this.initialMessage.edit(this._renderInitial());
138 | } else {
139 | if (signal)
140 | this.reply.html("\uD83D\uDC80 Killed by %s.", utils.formatSignal(signal));
141 | else if (code === 0)
142 | this.reply.html("\u2705 Exited correctly.");
143 | else
144 | this.reply.html("\u26D4 Exited with %s.", code);
145 | }
146 |
147 | this._removeKeypad();
148 | this.emit("exit");
149 | };
150 |
151 | Command.prototype.handleReply = function handleReply(msg) {
152 | //FIXME: feature: if photo, file, video, voice or music, put the terminal in raw mode, hold off further input, pipe binary asset to terminal, restore
153 | //Flags we would need to touch: -INLCR -IGNCR -ICRNL -IUCLC -ISIG -ICANON -IEXTEN, and also for convenience -ECHO -ECHONL
154 |
155 | if (msg.type !== "text") return false;
156 | this.sendInput(msg.text);
157 | };
158 |
159 | Command.prototype.sendInput = function sendInput(text, noTerminate) {
160 | this.interacted = true;
161 | text = text.replace(/\n/g, "\r");
162 | if (!noTerminate) text += "\r";
163 | if (this.metaActive) text = "\x1b" + text;
164 | this.pty.write(text);
165 | this.metaActive = false;
166 | };
167 |
168 | Command.prototype.toggleMeta = function toggleMeta(metaActive) {
169 | if (metaActive === undefined) metaActive = !this.metaActive;
170 | this.metaActive = metaActive;
171 | };
172 |
173 | Command.prototype.setSilent = function setSilent(silent) {
174 | this.renderer.options.silent = silent;
175 | };
176 |
177 | Command.prototype.setLinkPreviews = function setLinkPreviews(linkPreviews) {
178 | this.renderer.options.hidePreview = !linkPreviews;
179 | };
180 |
181 | Command.prototype._initKeypad = function _initKeypad() {
182 | this.keypadToken = utils.generateToken();
183 |
184 | var keys = {
185 | esc: { label: "ESC", content: "\x1b" },
186 | tab: { label: "⇥", content: "\t" },
187 | enter: { label: "⏎", content: "\r" },
188 | backspace: { label: "↤", content: "\x7F" },
189 | space: { label: " ", content: " " },
190 |
191 | up: { label: "↑", content: "\x1b[A", appKeypadContent: "\x1bOA" },
192 | down: { label: "↓", content: "\x1b[B", appKeypadContent: "\x1bOB" },
193 | right: { label: "→", content: "\x1b[C", appKeypadContent: "\x1bOC" },
194 | left: { label: "←", content: "\x1b[D", appKeypadContent: "\x1bOD" },
195 |
196 | insert: { label: "INS", content: "\x1b[2~" },
197 | del: { label: "DEL", content: "\x1b[3~" },
198 | home: { label: "⇱", content: "\x1bOH" },
199 | end: { label: "⇲", content: "\x1bOF" },
200 |
201 | prevPage: { label: "⇈", content: "\x1b[5~" },
202 | nextPage: { label: "⇊", content: "\x1b[6~" },
203 | };
204 |
205 | var keypad = [
206 | [ "esc", "up", "backspace", "del" ],
207 | [ "left", "space", "right", "home" ],
208 | [ "tab", "down", "enter", "end" ],
209 | ];
210 |
211 | this.buttons = [];
212 | this.inlineKeyboard = keypad.map(function (row) {
213 | return row.map(function (name) {
214 | var button = keys[name];
215 | var data = JSON.stringify({ token: this.keypadToken, button: this.buttons.length });
216 | var keyboardButton = { text: button.label, callback_data: data };
217 | this.buttons.push(button);
218 | return keyboardButton;
219 | }.bind(this));
220 | }.bind(this));
221 |
222 | this.reply.bot.callback(function (query, next) {
223 | try {
224 | var data = JSON.parse(query.data);
225 | } catch (e) { return next(); }
226 | if (data.token !== this.keypadToken) return next();
227 | this._keypadPressed(data.button, query);
228 | }.bind(this));
229 | };
230 |
231 | Command.prototype.toggleKeypad = function toggleKeypad() {
232 | if (this.keypadMessage) {
233 | this.keypadMessage.markup = null;
234 | this.keypadMessage.refresh();
235 | this.keypadMessage = null;
236 | return;
237 | }
238 |
239 | // FIXME: this is pretty badly implemented, we should wait until last message (or message with cursor) has known id
240 | var messages = this.renderer.messages;
241 | var msg = messages[messages.length - 1].ref;
242 | msg.markup = {inline_keyboard: this.inlineKeyboard};
243 | msg.refresh();
244 | this.keypadMessage = msg;
245 | };
246 |
247 | Command.prototype._keypadPressed = function _keypadPressed(id, query) {
248 | this.interacted = true;
249 | if (typeof id !== "number" || !(id in this.buttons)) return;
250 | var button = this.buttons[id];
251 | var content = button.content;
252 | if (button.appKeypadContent !== undefined && this.state.getMode("appKeypad"))
253 | content = button.appKeypadContent;
254 | this.pty.write(content);
255 | query.answer();
256 | };
257 |
258 | Command.prototype._removeKeypad = function _removeKeypad() {
259 | if (this.keypadMessage) this.toggleKeypad();
260 | };
261 |
262 |
263 |
264 | exports.Command = Command;
265 |
--------------------------------------------------------------------------------
/lib/editor.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Implements a simple select-replace file editor in Telegram.
3 | * It works as follows:
4 | *
5 | * 1. The user invokes the editor with a non-empty file.
6 | * 2. The contents of the file are posted as a message.
7 | * 3. The user replies to that message with (part of) the text.
8 | * The bot will locate that substring in the file contents and track the message.
9 | * 4. The user edits his message.
10 | * The bot will then replace the original substring, save the file and edit its message.
11 | * If there are any problems with saving the file, the editor may detach.
12 | *
13 | * NOTE: sync I/O is used for simplicity; be careful! (TODO)
14 | **/
15 |
16 | var fs = require("fs");
17 | var escapeHtml = require("escape-html");
18 | var utils = require("./utils");
19 |
20 | function ChunkedString(text) {
21 | this.text = text;
22 | this.chunks = [];
23 | }
24 |
25 | ChunkedString.prototype.findAcquire = function findAcquire(text) {
26 | if (text.length == 0) throw Error("Empty find text not allowed");
27 | var index = this.text.indexOf(text);
28 | if (index == -1)
29 | throw Error("The substring was not found. Wrapping in tildes may be necessary.");
30 | if (index != this.text.lastIndexOf(text))
31 | throw Error("There are multiple instances of the passed substring");
32 | return this.acquire(index, text.length);
33 | };
34 |
35 | ChunkedString.prototype.acquire = function acquire(offset, length) {
36 | if (offset < 0 || length <= 0 || offset + length > this.text.length)
37 | throw Error("Invalid coordinates");
38 | for (var i = 0; i < this.chunks.length; i++) {
39 | var c = this.chunks[i];
40 | if (offset + length > c.offset || c.offset + c.text.length > offset)
41 | throw Error("Chunk overlaps");
42 | }
43 | var chunk = { offset: offset, text: this.text.substring(offset, offset + length) };
44 | this.chunks.push(chunk);
45 | return chunk;
46 | };
47 |
48 | ChunkedString.prototype.release = function release(chunk) {
49 | if (this.chunks.indexOf(chunk) == -1) throw Error("Invalid chunk given");
50 | this.chunks.splice(index, 1);
51 | };
52 |
53 | ChunkedString.prototype.modify = function modify(chunk, text) {
54 | if (this.chunks.indexOf(chunk) == -1) throw Error("Invalid chunk given");
55 | if (text.length == 0) throw Error("Empty replacement not allowed");
56 | var end = chunk.offset + chunk.text.length;
57 | this.text = this.text.substring(0, chunk.offset) + text + this.text.substring(end);
58 | var diff = text.length - chunk.text.length;
59 | chunk.text = text;
60 | this.chunks.forEach(function (c) {
61 | if (c.offset > chunk.offset) c.offset += diff;
62 | });
63 | };
64 |
65 |
66 | function Editor(reply, file, encoding) {
67 | if (!encoding) encoding = "utf-8";
68 | this.reply = reply;
69 | this.file = file;
70 | this.encoding = encoding;
71 |
72 | // TODO: support for longer files (paginated, etc.)
73 | // FIXME: do it correctly using fd, keeping it open
74 | var contents = fs.readFileSync(file, encoding);
75 | if (contents.length > 1500 || contents.split("\n") > 50)
76 | throw Error("The file is too long");
77 |
78 | this.contents = new ChunkedString(contents);
79 | this.chunks = {}; // associates each message ID to an active chunk
80 |
81 | this.message = new utils.EditedMessage(reply, this._render(), "HTML");
82 | this.fileTouched = false;
83 | }
84 |
85 | Editor.prototype._render = function _render() {
86 | if (!this.contents.text.trim()) return "(empty file)";
87 | return "" + escapeHtml(this.contents.text) + "
";
88 | };
89 |
90 | Editor.prototype.handleReply = function handleReply(msg) {
91 | this.message.idPromise.then(function (id) {
92 | if (this.detached) return;
93 | if (msg.reply.id != id) return;
94 | try {
95 | this.chunks[msg.id] = this.contents.findAcquire(msg.text);
96 | } catch (e) {
97 | this.reply.html("%s", e.message);
98 | }
99 | }.bind(this));
100 | };
101 |
102 | Editor.prototype.handleEdit = function handleEdit(msg) {
103 | if (this.detached) return false;
104 | if (!Object.hasOwnProperty.call(this.chunks, msg.id)) return false;
105 | this.contents.modify(this.chunks[msg.id], msg.text);
106 | this.attemptSave();
107 | return true;
108 | };
109 |
110 | Editor.prototype.attemptSave = function attemptSave() {
111 | this.fileTouched = true;
112 | process.nextTick(function () {
113 | if (!this.fileTouched) return;
114 | if (this.detached) return;
115 | this.fileTouched = false;
116 |
117 | // TODO: check for file external modification, fail then
118 | try {
119 | fs.writeFileSync(this.file, this.contents.text, this.encoding);
120 | } catch (e) {
121 | this.reply.html("Couldn't save file: %s", e.message);
122 | return;
123 | }
124 | this.message.edit(this._render());
125 | }.bind(this));
126 | };
127 |
128 | Editor.prototype.detach = function detach() {
129 | this.detached = true;
130 | };
131 |
132 | module.exports.Editor = Editor;
133 |
--------------------------------------------------------------------------------
/lib/renderer.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This class keeps a logical mapping of lines to messages.
3 | * It doesn't actually render or send messages, it delegates
4 | * that task to the renderer.
5 | *
6 | * FIXME: do something to prevent extremely long messages to be
7 | * sent (and rejected) when too many lines are inserted in between
8 | * a message.
9 | **/
10 |
11 | var escapeHtml = require("escape-html");
12 | var utils = require("./utils");
13 |
14 | function Renderer(reply, state, options) {
15 | if (!options) options = {};
16 | this.reply = reply;
17 | this.state = state;
18 | this.options = options;
19 |
20 | this.offset = 0;
21 | this.messages = [];
22 | this.orphanLines = [];
23 | this.unfinishedLine = null;
24 | this.totalLines = 0;
25 |
26 | state.on("lineChanged", this._lineChanged.bind(this));
27 | state.on("linesRemoving", this._linesRemoving.bind(this));
28 | state.on("linesScrolling", this._linesScrolling.bind(this));
29 | state.on("linesInserted", this._linesInserted.bind(this));
30 |
31 | this.initTimers();
32 | }
33 |
34 |
35 | /** MESSAGE MAPPING **/
36 |
37 | Renderer.prototype.ensureLinesCreated = function ensureLinesCreated(y) {
38 | if (this.totalLines < y) {
39 | this.orphanLines = this.orphanLines.concat(this.state.lines.slice(this.totalLines, y));
40 | this.totalLines = y;
41 | this.newLinesChanged = true;
42 | }
43 | };
44 |
45 | Renderer.prototype._lineChanged = function _lineChanged(y) {
46 | if (this.state.length - y <= this.orphanLines.length)
47 | this.newLinesChanged = true;
48 | };
49 |
50 | Renderer.prototype._linesRemoving = function _linesRemoving(y, n) {
51 | this.ensureLinesCreated(this.state.lines.length);
52 |
53 | // Seek until we arrive at the wanted line
54 | y += this.offset;
55 | var idx = 0, lineIdx = 0;
56 | while (y) {
57 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines;
58 | if (lineIdx < lines.length) { lineIdx++; y--; }
59 | else { idx++; lineIdx = 0; }
60 | }
61 |
62 | // Remove following lines
63 | this.totalLines -= n;
64 | while (n) {
65 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines;
66 | if (lines.splice(lineIdx, 1).length) n--;
67 | else { idx++; lineIdx = 0; }
68 | }
69 |
70 | if (idx >= this.messages.length) this.newLinesChanged = true;
71 | };
72 |
73 | Renderer.prototype._linesScrolling = function _linesScrolling(n) {
74 | this.ensureLinesCreated(this.state.lines.length);
75 |
76 | if (n > 0) {
77 | // Scrolling up: increment offset, discarding message if necessary
78 | this.offset += n;
79 | this.totalLines -= n;
80 | while (this.messages.length) {
81 | var message = this.messages[0];
82 | if (message.lines.length > this.offset) break;
83 | if (message.rendered !== message.ref.lastText) break;
84 | this.offset -= message.lines.length;
85 | this.messages.shift();
86 | }
87 | } else {
88 | // Scrolling down: just delete bottom lines (leaving them would complicate everything)
89 | n = -n;
90 | this._linesRemoving(this.state.lines.length - n, n);
91 | }
92 | };
93 |
94 | Renderer.prototype._linesInserted = function _linesInserted(y, n) {
95 | this.ensureLinesCreated(y);
96 | var pos = y;
97 |
98 | // Seek until we arrive at the wanted line, *just before the next one*
99 | y += this.offset;
100 | var idx = 0, lineIdx = 0;
101 | while (true) {
102 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines;
103 | if (lineIdx < lines.length) {
104 | if (!y) break;
105 | lineIdx++; y--;
106 | } else { idx++; lineIdx = 0; }
107 | }
108 |
109 | // Insert lines
110 | this.totalLines += n;
111 | while (n) {
112 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines;
113 | lines.splice(lineIdx, 0, this.state.lines[pos]);
114 | n--, lineIdx++, pos++;
115 | }
116 |
117 | if (idx === this.messages.length) this.newLinesChanged = true;
118 | };
119 |
120 | Renderer.prototype.update = function update() {
121 | this.ensureLinesCreated(this.state.lines.length);
122 |
123 | // Rerender messages, scheduling flush if some changed
124 | var linesChanged = false;
125 | this.messages.forEach(function (message) {
126 | var rendered = this.render(message);
127 | if (rendered !== message.rendered) {
128 | message.rendered = rendered;
129 | linesChanged = true;
130 | }
131 | }.bind(this));
132 |
133 | if (linesChanged) this.editedLineTimer.set();
134 | if (this.newLinesChanged) this.newLineTimer.reset();
135 | this.newLinesChanged = false;
136 |
137 | // Make sure orphan lines are processed
138 | this.orphanLinesUpdated();
139 | };
140 |
141 | Renderer.prototype.emitMessage = function emitMessage(count, silent, disablePreview) {
142 | if (count < 0 || count > this.orphanLines.length) throw new Error("Should not happen.");
143 |
144 | if (count > this.options.maxLinesEmitted)
145 | count = this.options.maxLinesEmitted;
146 | var lines = this.orphanLines.splice(0, count);
147 | var message = { lines: lines };
148 | this.messages.push(message);
149 | message.rendered = this.render(message);
150 | var reply = this.reply.silent(silent).disablePreview(disablePreview);
151 | message.ref = new utils.EditedMessage(reply, message.rendered, "HTML");
152 | this.orphanLinesUpdated();
153 | };
154 |
155 |
156 | /** HTML RENDERING **/
157 |
158 | /* Given a line, return true if potentially monospaced */
159 | Renderer.prototype.evaluateCode = function evaluateCode(str) {
160 | //FIXME: line just between two code lines should be come code
161 | if (str.indexOf(" ") !== -1 || /[-_,:;<>()/\\~|'"=^]{4}/.exec(str))
162 | return true;
163 | return false;
164 | };
165 |
166 | /* Given a message object, render to an HTML snippet */
167 | Renderer.prototype.render = function render(message) {
168 | var cursorString = this.state.getMode("cursorBlink") ? this.options.cursorBlinkString : this.options.cursorString;
169 | var isWhitespace = true, x = this.state.cursor[0];
170 |
171 | var html = message.lines.map(function (line, idx) {
172 | var hasCursor = (this.state.getMode("cursor")) && (this.state.getLine() === line);
173 | if (!line.code && this.evaluateCode(line.str)) line.code = true;
174 |
175 | var content = line.str;
176 | if (hasCursor || line.str.trim().length) isWhitespace = false;
177 | if (idx === 0 && !content.substring(0, this.options.startFill.length).trim()) {
178 | // The message would start with spaces, which would get trimmed by telegram
179 | if (!(hasCursor && x < this.options.startFill.length))
180 | content = this.options.startFill + content.substring(this.options.startFill.length);
181 | }
182 |
183 | if (hasCursor)
184 | content = escapeHtml(content.substring(0, x)) + cursorString + escapeHtml(content.substring(x));
185 | else
186 | content = escapeHtml(content);
187 |
188 | if (line.code) content = "" + content + "
";
189 | return content;
190 | }.bind(this)).join("\n");
191 |
192 | if (isWhitespace) return "(empty)";
193 | return html;
194 | };
195 |
196 |
197 | /** FLUSH SCHEDULING **/
198 |
199 | Renderer.prototype.initTimers = function initTimers() {
200 | // Set when an existent line changes, cancelled when edited lines flushed
201 | this.editedLineTimer = new utils.Timer(this.options.editTime).on("fire", this.flushEdited.bind(this));
202 |
203 | // Set when a new line is added or changed, cancelled on new lines flush
204 | this.newChunkTimer = new utils.Timer(this.options.chunkTime).on("fire", this.flushNew.bind(this));
205 | // Reset when a new line is added or changed, cancelled on new lines flush
206 | this.newLineTimer = new utils.Timer(this.options.lineTime).on("fire", this.flushNew.bind(this));
207 |
208 | // Set when there is an unfinished nonempty line, cancelled when reference changes or line becomes empty
209 | this.unfinishedLineTimer = new utils.Timer(this.options.unfinishedTime).on("fire", this.flushUnfinished.bind(this));
210 |
211 | this.newChunkTimer.on("active", function () {
212 | this.reply.action("typing");
213 | }.bind(this));
214 | //FIXME: should we emit actions on edits?
215 | };
216 |
217 | Renderer.prototype.orphanLinesUpdated = function orphanLinesUpdated() {
218 | var newLines = this.orphanLines.length - 1;
219 | if (newLines >= this.options.maxLinesWait) {
220 | // Flush immediately
221 | this.flushNew();
222 | } else if (newLines > 0) {
223 | this.newChunkTimer.set();
224 | } else {
225 | this.newChunkTimer.cancel();
226 | this.newLineTimer.cancel();
227 | }
228 |
229 | // Update unfinished line
230 | var unfinishedLine = this.orphanLines[this.orphanLines.length - 1];
231 | if (unfinishedLine && this.totalLines === this.state.rows && unfinishedLine.str.length === this.state.columns)
232 | unfinishedLine = null;
233 |
234 | if (this.unfinishedLine !== unfinishedLine) {
235 | this.unfinishedLine = unfinishedLine;
236 | this.unfinishedLineTimer.cancel();
237 | }
238 |
239 | if (unfinishedLine && unfinishedLine.str.length) this.unfinishedLineTimer.set();
240 | else this.unfinishedLineTimer.cancel();
241 | };
242 |
243 | Renderer.prototype.flushEdited = function flushEdited() {
244 | this.messages.forEach(function (message) {
245 | if (message.rendered !== message.ref.lastText)
246 | message.ref.edit(message.rendered);
247 | });
248 | this.editedLineTimer.cancel();
249 | };
250 |
251 | Renderer.prototype.flushNew = function flushNew() {
252 | this.flushEdited();
253 | var count = this.orphanLines.length;
254 | if (this.unfinishedLine) count--;
255 | if (count <= 0) return;
256 | this.emitMessage(count, !!this.options.silent, !!this.options.hidePreview);
257 | };
258 |
259 | Renderer.prototype.flushUnfinished = function flushUnfinished() {
260 | do this.flushNew(); while (this.orphanLines.length > 1);
261 | if (this.orphanLines.length < 1 || this.orphanLines[0].str.length === 0) return;
262 | this.emitMessage(1, !!this.options.unfinishedSilent, !!this.options.unfinishedHidePreview);
263 | };
264 |
265 |
266 |
267 | exports.Renderer = Renderer;
268 |
--------------------------------------------------------------------------------
/lib/terminal.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Implements a terminal emulator. We use terminal.js for the
3 | * dirty work of parsing the escape sequences, but implement our
4 | * own TermState, with some quirks when compared to a standard
5 | * terminal emulator:
6 | *
7 | * - Lines and characters are created on demand. The terminal
8 | * starts out with no content. The reason being, you don't want
9 | * empty lines to be immediately pushed to your Telegram chat
10 | * after starting a command.
11 | *
12 | * - Allows lines of length higher than the column size. The extra
13 | * characters are appended but the cursor keeps right at the edge.
14 | * Telegram already wraps long lines, having them wrapped by the
15 | * terminal would be ugly.
16 | *
17 | * - Graphic attributes not implemented for now (would not be used
18 | * for Telegram anyways).
19 | *
20 | * - Doesn't have an alternate buffer for now (wouldn't make much
21 | * sense for Telegram rendering...) FIXME
22 | *
23 | * The terminal emulator emits events when lines get inserted, changed,
24 | * removed or go out of view, similar to the original TermState.
25 | **/
26 |
27 | var util = require("util");
28 | var Terminal = require("terminal.js");
29 |
30 | //FIXME: investigate using a patched palette for better support on android
31 | var GRAPHICS = {
32 | '`': '\u25C6',
33 | 'a': '\u2592',
34 | 'b': '\u2409',
35 | 'c': '\u240C',
36 | 'd': '\u240D',
37 | 'e': '\u240A',
38 | 'f': '\u00B0',
39 | 'g': '\u00B1',
40 | 'h': '\u2424',
41 | 'i': '\u240B',
42 | 'j': '\u2518',
43 | 'k': '\u2510',
44 | 'l': '\u250C',
45 | 'm': '\u2514',
46 | 'n': '\u253C',
47 | 'o': '\u23BA',
48 | 'p': '\u23BB',
49 | 'q': '\u2500',
50 | 'r': '\u23BC',
51 | 's': '\u23BD',
52 | 't': '\u251C',
53 | 'u': '\u2524',
54 | 'v': '\u2534',
55 | 'w': '\u252C',
56 | 'x': '\u2502',
57 | 'y': '\u2264',
58 | 'z': '\u2265',
59 | '{': '\u03C0',
60 | '|': '\u2260',
61 | '}': '\u00A3',
62 | '~': '\u00B7'
63 | };
64 |
65 |
66 | /** INITIALIZATION & ACCESSORS **/
67 |
68 | function TermState(options) {
69 | if (!options) options = {};
70 | this.rows = options.rows || 24;
71 | this.columns = options.columns || 80;
72 |
73 | this.defaultAttributes = {
74 | fg: null,
75 | bg: null,
76 | bold: false,
77 | underline: false,
78 | italic: false,
79 | blink: false,
80 | inverse: false,
81 | };
82 | this.reset();
83 | }
84 | util.inherits(TermState, require("events").EventEmitter);
85 |
86 | TermState.prototype.reset = function reset() {
87 | this.lines = [];
88 | this.cursor = [0,0];
89 | this.savedCursor = [0,0];
90 |
91 | this.modes = {
92 | cursor: true,
93 | cursorBlink: false,
94 | appKeypad: false,
95 | wrap: true,
96 | insert: false,
97 | crlf: false,
98 | mousebtn: false,
99 | mousemtn: false,
100 | reverse: false,
101 | graphic: false,
102 | mousesgr: false,
103 | };
104 | this.attributes = Object.create(this.defaultAttributes);
105 | this._charsets = {
106 | "G0": "unicode",
107 | "G1": "unicode",
108 | "G2": "unicode",
109 | "G3": "unicode",
110 | };
111 | this._mappedCharset = "G0";
112 | this._mappedCharsetNext = "G0";
113 | this.metas = {
114 | title: '',
115 | icon: ''
116 | };
117 | this.leds = {};
118 | this._tabs = [];
119 | this.emit("reset");
120 | };
121 |
122 | function getGenericSetter(field) {
123 | return function genericSetter(name, value) {
124 | this[field + "s"][name] = value;
125 | this.emit(field, name);
126 | };
127 | }
128 |
129 | TermState.prototype.setMode = getGenericSetter("mode");
130 | TermState.prototype.setMeta = getGenericSetter("meta");
131 | TermState.prototype.setAttribute = getGenericSetter("attribute");
132 | TermState.prototype.setLed = getGenericSetter("led");
133 |
134 | TermState.prototype.getMode = function getMode(mode) {
135 | return this.modes[mode];
136 | };
137 |
138 | TermState.prototype.getLed = function getLed(led) {
139 | return !!this.leds[led];
140 | };
141 |
142 | TermState.prototype.ledOn = function ledOn(led) {
143 | this.setLed(led, true);
144 | return this;
145 | };
146 |
147 | TermState.prototype.resetLeds = function resetLeds() {
148 | this.leds = {};
149 | return this;
150 | };
151 |
152 | TermState.prototype.resetAttribute = function resetAttribute(name) {
153 | this.attributes[name] = this.defaultAttributes[name];
154 | return this;
155 | };
156 |
157 | TermState.prototype.mapCharset = function(target, nextOnly) {
158 | this._mappedCharset = target;
159 | if (!nextOnly) this._mappedCharsetNext = target;
160 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility
161 | };
162 |
163 | TermState.prototype.selectCharset = function(charset, target) {
164 | if (!target) target = this._mappedCharset;
165 | this._charsets[target] = charset;
166 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility
167 | };
168 |
169 |
170 | /** CORE METHODS **/
171 |
172 | /* Move the cursor */
173 | TermState.prototype.setCursor = function setCursor(x, y) {
174 | if (typeof x === 'number')
175 | this.cursor[0] = x;
176 |
177 | if (typeof y === 'number')
178 | this.cursor[1] = y;
179 |
180 | this.cursor = this.getCursor();
181 | this.emit("cursor");
182 | return this;
183 | };
184 |
185 | /* Get the real cursor position (the logical one may be off-bounds) */
186 | TermState.prototype.getCursor = function getCursor() {
187 | var x = this.cursor[0], y = this.cursor[1];
188 |
189 | if (x >= this.columns) x = this.columns - 1;
190 | else if (x < 0) x = 0;
191 |
192 | if (y >= this.rows) y = this.rows - 1;
193 | else if (y < 0) y = 0;
194 |
195 | return [x,y];
196 | };
197 |
198 | /* Get the line at specified position, allocating it if necessary */
199 | TermState.prototype.getLine = function getLine(y) {
200 | if (typeof y !== "number") y = this.getCursor()[1];
201 | if (y < 0) throw new Error("Invalid position to write to");
202 |
203 | // Insert lines until the line at this position is available
204 | while (!(y < this.lines.length))
205 | this.lines.push({ str: "", attr: null });
206 |
207 | return this.lines[y];
208 | };
209 |
210 | /* Replace the line at specified position, allocating it if necessary */
211 | TermState.prototype.setLine = function setLine(y, line) {
212 | if (typeof y !== "number") line = y, y = this.getCursor()[1];
213 | this.getLine(y);
214 | this.lines[y] = line;
215 | return this;
216 | };
217 |
218 | /* Write chunk of text (single-line assumed) beginning at position */
219 | TermState.prototype._writeChunk = function _writeChunk(position, chunk, insert) {
220 | var x = position[0], line = this.getLine(position[1]);
221 | if (x < 0) throw new Error("Invalid position to write to");
222 |
223 | // Insert spaces until the wanted column is available
224 | while (line.str.length < x)
225 | line.str += " ";
226 |
227 | // Write the chunk at position
228 | line.str = line.str.substring(0, x) + chunk + line.str.substring(x + (insert ? 0 : chunk.length));
229 | //TODO: add attribute
230 |
231 | this.emit("lineChanged", position[1]);
232 | return this;
233 | };
234 |
235 | /* Remove characters beginning at position */
236 | TermState.prototype.removeChar = function removeChar(n) {
237 | var x = this.cursor[0], line = this.getLine();
238 | if (x < 0) throw new Error("Invalid position to delete from");
239 |
240 | // Insert spaces until the wanted column is available
241 | while (line.str.length < x)
242 | line.str += " ";
243 |
244 | // Remove characters
245 | line.str = line.str.substring(0, x) + line.str.substring(x + n);
246 |
247 | this.emit("lineChanged", this.cursor[1]);
248 | return this;
249 | };
250 |
251 | TermState.prototype.eraseInLine = function eraseInLine(n) {
252 | var x = this.cursor[0], line = this.getLine();
253 | switch (n || 0) {
254 | case "after":
255 | case 0:
256 | line.str = line.str.substring(0, x);
257 | break;
258 |
259 | case "before":
260 | case 1:
261 | var str = "";
262 | while (str.length < x) str += " ";
263 | line.str = str + line.str.substring(x);
264 | break;
265 |
266 | case "all":
267 | case 2:
268 | line.str = "";
269 | break;
270 | }
271 | this.emit("lineChanged", this.cursor[1]);
272 | return this;
273 | };
274 |
275 | TermState.prototype.eraseInDisplay = function eraseInDisplay(n) {
276 | switch (n || 0) {
277 | case "below":
278 | case "after":
279 | case 0:
280 | this.eraseInLine(n);
281 | this.removeLine(this.lines.length - (this.cursor[1]+1), this.cursor[1]+1);
282 | break;
283 |
284 | case "above":
285 | case "before":
286 | case 1:
287 | for (var y = 0; y < this.cursor[1]; y++) {
288 | this.lines[y].str = "";
289 | this.emit("lineChanged", y);
290 | }
291 | this.eraseInLine(n);
292 | break;
293 |
294 | case "all":
295 | case 2:
296 | this.removeLine(this.lines.length, 0);
297 | break;
298 | }
299 | return this;
300 | };
301 |
302 | TermState.prototype.removeLine = function removeLine(n, y) {
303 | if (typeof y !== "number") y = this.cursor[1];
304 | if (n <= 0) return this;
305 |
306 | if (y + n > this.lines.length)
307 | n = this.lines.length - y;
308 | if (n <= 0) return this;
309 |
310 | this.emit("linesRemoving", y, n);
311 | this.lines.splice(y, n);
312 | return this;
313 | };
314 |
315 | TermState.prototype.insertLine = function insertLine(n, y) {
316 | if (typeof y !== "number") y = this.cursor[1];
317 | if (n <= 0) return this;
318 |
319 | if (y + n > this.rows)
320 | n = this.rows - y;
321 | if (n <= 0) return this;
322 |
323 | this.getLine(y);
324 | this.removeLine((this.lines.length + n) - this.rows, this.rows - n);
325 | for (var i = 0; i < n; i++)
326 | this.lines.splice(y, 0, { str: "", attr: null });
327 | this.emit("linesInserted", y, n);
328 | return this;
329 | };
330 |
331 | TermState.prototype.scroll = function scroll(n) {
332 | if (n > 0) { // up
333 | if (n > this.lines.length) n = this.lines.length; //FIXME: is this okay?
334 | if (n > 0) this.emit("linesScrolling", n);
335 | this.lines = this.lines.slice(n);
336 | } else if (n < 0) { // down
337 | n = -n;
338 | if (n > this.rows) n = this.rows; //FIXME: is this okay?
339 | var extraLines = (this.lines.length + n) - this.rows;
340 | if (extraLines > 0) this.emit("linesScrolling", -extraLines);
341 | this.lines = this.lines.slice(0, this.rows - n);
342 | this.insertLine(n, 0);
343 | }
344 | return this;
345 | };
346 |
347 |
348 | /** HIGH LEVEL **/
349 |
350 | TermState.prototype._graphConvert = function(content) {
351 | // optimization for 99% of the time
352 | if(this._mappedCharset === this._mappedCharsetNext && !this.modes.graphic) {
353 | return content;
354 | }
355 |
356 | var result = "", i;
357 | for(i = 0; i < content.length; i++) {
358 | result += (this.modes.graphic && content[i] in GRAPHICS) ?
359 | GRAPHICS[content[i]] :
360 | content[i];
361 | this._mappedCharset = this._mappedCharsetNext;
362 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility
363 | }
364 | return result;
365 | };
366 |
367 | TermState.prototype.write = function write(chunk) {
368 | chunk.split("\n").forEach(function (line, i) {
369 | if (i > 0) {
370 | // Begin new line
371 | if (this.cursor[1] + 1 >= this.rows)
372 | this.scroll(1);
373 | this.mvCursor(0, 1);
374 | this.getLine();
375 | }
376 |
377 | if (!line.length) return;
378 | if (this.getMode("graphic")) this.getLine().code = true;
379 | line = this._graphConvert(line);
380 | this._writeChunk(this.cursor, line, this.getMode("insert"));
381 | this.cursor[0] += line.length;
382 | }.bind(this));
383 | this.emit("cursor");
384 | return this;
385 | };
386 |
387 | TermState.prototype.resize = function resize(size) {
388 | if (this.lines.length > size.rows)
389 | this.scroll(this.lines.length - size.rows);
390 | this.rows = size.rows;
391 | this.columns = size.columns;
392 | this.setCursor();
393 | this.emit("resize", size);
394 | return this;
395 | };
396 |
397 | TermState.prototype.mvCursor = function mvCursor(x, y) {
398 | var cursor = this.getCursor();
399 | return this.setCursor(cursor[0] + x, cursor[1] + y);
400 | };
401 |
402 | TermState.prototype.toString = function toString() {
403 | return this.lines.map(function (line) { return line.str; }).join("\n");
404 | };
405 |
406 | TermState.prototype.prevLine = function prevLine() {
407 | if (this.cursor[1] > 0) this.mvCursor(0, -1);
408 | else this.scroll(-1);
409 | return this;
410 | };
411 |
412 | TermState.prototype.nextLine = function nextLine() {
413 | if (this.cursor[1] < this.rows - 1) this.mvCursor(0, +1);
414 | else this.scroll(+1);
415 | return this;
416 | };
417 |
418 | TermState.prototype.saveCursor = function saveCursor() {
419 | this.savedCursor = this.getCursor();
420 | return this;
421 | };
422 |
423 | TermState.prototype.restoreCursor = function restoreCursor() {
424 | this.cursor = this.savedCursor;
425 | return this.setCursor();
426 | };
427 |
428 | TermState.prototype.insertBlank = function insertBlank(n) {
429 | var str = "";
430 | while (str.length < n) str += " ";
431 | return this._writeChunk(this.cursor, str, true);
432 | };
433 |
434 | TermState.prototype.eraseCharacters = function eraseCharacters(n) {
435 | var str = "";
436 | while (str.length < n) str += " ";
437 | return this._writeChunk(this.cursor, str, false);
438 | };
439 |
440 | TermState.prototype.setScrollRegion = function setScrollRegion(n, m) {
441 | //TODO
442 | return this;
443 | };
444 |
445 | TermState.prototype.switchBuffer = function switchBuffer(alt) {
446 | if (this.alt !== alt) {
447 | this.scroll(this.lines.length);
448 | this.alt = alt;
449 | }
450 | return this;
451 | };
452 |
453 | TermState.prototype.getBufferRowCount = function getBufferRowCount() {
454 | return this.lines.length;
455 | };
456 |
457 |
458 | /**
459 | * moves Cursor forward or backward a specified amount of tabs
460 | * @param n {number} - number of tabs to move. <0 moves backward, >0 moves
461 | * forward
462 | */
463 | TermState.prototype.mvTab = function(n) {
464 | var x = this.getCursor()[0];
465 | var tabMax = this._tabs[this._tabs.length - 1] || 0;
466 | var positive = n > 0;
467 | n = Math.abs(n);
468 | while(n !== 0 && x > 0 && x < this.columns-1) {
469 | x += positive ? 1 : -1;
470 | if(this._tabs.indexOf(x) != -1 || (x > tabMax && x % 8 === 0))
471 | n--;
472 | }
473 | this.setCursor(x);
474 | };
475 |
476 | /**
477 | * set tab at specified position
478 | * @param pos {number} - position to set a tab at
479 | */
480 | TermState.prototype.setTab = function(pos) {
481 | // Set the default to current cursor if no tab position is specified
482 | if(pos === undefined) {
483 | pos = this.getCursor()[0];
484 | }
485 | // Only add the tab position if it is not there already
486 | if (this._tabs.indexOf(pos) != -1) {
487 | this._tabs.push(pos);
488 | this._tabs.sort();
489 | }
490 | };
491 |
492 | /**
493 | * remove a tab
494 | * @param pos {number} - position to remove a tab. Do nothing if the tab isn't
495 | * set at this position
496 | */
497 | TermState.prototype.removeTab = function(pos) {
498 | var i, tabs = this._tabs;
499 | for(i = 0; i < tabs.length && tabs[i] !== pos; i++);
500 | tabs.splice(i, 1);
501 | };
502 |
503 | /**
504 | * removes a tab at a given index
505 | * @params n {number} - can be one of the following
506 | *
507 | * - "current" or 0: searches tab at current position. no tab is at current
508 | * position delete the next tab
509 | * - "all" or 3: deletes all tabs
510 | */
511 | TermState.prototype.tabClear = function(n) {
512 | switch(n || "current") {
513 | case "current":
514 | case 0:
515 | for(var i = this._tabs.length - 1; i >= 0; i--) {
516 | if(this._tabs[i] < this.getCursor()[0]) {
517 | this._tabs.splice(i, 1);
518 | break;
519 | }
520 | }
521 | break;
522 | case "all":
523 | case 3:
524 | this._tabs = [];
525 | break;
526 | }
527 | };
528 |
529 |
530 |
531 | function createTerminal(options) {
532 | var state = new TermState(options);
533 | var term = new Terminal({});
534 | term.state = state;
535 | return term;
536 | }
537 |
538 | exports.TermState = TermState;
539 | exports.createTerminal = createTerminal;
540 |
--------------------------------------------------------------------------------
/lib/utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Miscellaneous utilities.
3 | **/
4 |
5 | var fs = require("fs");
6 | var util = require("util");
7 | var mime = require("mime");
8 | var crypto = require("crypto");
9 | var url = require("url");
10 |
11 |
12 | /** TIMER **/
13 |
14 | function Timer(delay) {
15 | this.delay = delay;
16 | }
17 | util.inherits(Timer, require("events").EventEmitter);
18 |
19 | /* Starts the timer, does nothing if started already. */
20 | Timer.prototype.set = function set() {
21 | if (this.timeout) return;
22 | this.timeout = setTimeout(function () {
23 | this.timeout = null;
24 | this.emit("fire");
25 | }.bind(this), this.delay);
26 | this.emit("active");
27 | };
28 |
29 | /* Cancels the timer if set. */
30 | Timer.prototype.cancel = function cancel() {
31 | if (!this.timeout) return;
32 | clearTimeout(this.timeout);
33 | delete this.timeout;
34 | };
35 |
36 | /* Starts the timer, cancelling first if set. */
37 | Timer.prototype.reset = function reset() {
38 | this.cancel();
39 | this.set();
40 | };
41 |
42 | /** EDITED MESSAGE **/
43 |
44 | function EditedMessage(reply, text, mode) {
45 | this.reply = reply;
46 | this.mode = mode;
47 |
48 | this.lastText = text;
49 | this.markup = reply.parameters["reply_markup"];
50 | this.disablePreview = reply.parameters["disable_web_page_preview"];
51 | this.text = text;
52 | this.callbacks = [];
53 | this.pendingText = null;
54 | this.pendingCallbacks = [];
55 |
56 | this.idPromise = new Promise(function (resolve, reject) {
57 | reply.text(this.text, this.mode).then(function (err, msg) {
58 | if (err) reject(err);
59 | else resolve(msg.id);
60 | this._whenEdited(err, msg);
61 | }.bind(this));
62 | }.bind(this));
63 | }
64 | util.inherits(EditedMessage, require("events").EventEmitter);
65 |
66 | EditedMessage.prototype.refresh = function refresh(callback) {
67 | if (callback) this.pendingCallbacks.push(callback);
68 | this.pendingText = this.lastText;
69 | if (this.callbacks === undefined) this._flushEdit();
70 | };
71 |
72 | EditedMessage.prototype.edit = function edit(text, callback) {
73 | this.lastText = text;
74 | var idle = this.callbacks === undefined;
75 | if (callback) this.pendingCallbacks.push(callback);
76 |
77 | if (text === this.text) {
78 | this.callbacks = (this.callbacks || []).concat(this.pendingCallbacks);
79 | this.pendingText = null;
80 | this.pendingCallbacks = [];
81 | if (idle) this._whenEdited();
82 | } else {
83 | this.pendingText = text;
84 | if (idle) this._flushEdit();
85 | }
86 | };
87 |
88 | EditedMessage.prototype._flushEdit = function _flushEdit() {
89 | this.text = this.pendingText;
90 | this.callbacks = this.pendingCallbacks;
91 | this.pendingText = null;
92 | this.pendingCallbacks = [];
93 | this.reply.parameters["reply_markup"] = this.markup;
94 | this.reply.parameters["disable_web_page_preview"] = this.disablePreview;
95 | this.reply.editText(this.id, this.text, this.mode).then(this._whenEdited.bind(this));
96 | };
97 |
98 | EditedMessage.prototype._whenEdited = function _whenEdited(err, msg) {
99 | if (err) this.emit(this.id === undefined ? "error" : "editError", err);
100 | if (this.id === undefined) this.id = msg.id;
101 | var callbacks = this.callbacks;
102 | delete this.callbacks;
103 | callbacks.forEach(function (callback) { callback(); });
104 | if (this.pendingText !== null) this._flushEdit();
105 | };
106 |
107 | /** SANITIZED ENV **/
108 |
109 | function getSanitizedEnv() {
110 | // Adapted from pty.js source
111 | var env = {};
112 | Object.keys(process.env).forEach(function (key) {
113 | env[key] = process.env[key];
114 | });
115 |
116 | // Make sure we didn't start our
117 | // server from inside tmux.
118 | delete env.TMUX;
119 | delete env.TMUX_PANE;
120 |
121 | // Make sure we didn't start
122 | // our server from inside screen.
123 | // http://web.mit.edu/gnu/doc/html/screen_20.html
124 | delete env.STY;
125 | delete env.WINDOW;
126 |
127 | // Delete some variables that
128 | // might confuse our terminal.
129 | delete env.WINDOWID;
130 | delete env.TERMCAP;
131 | delete env.COLUMNS;
132 | delete env.LINES;
133 |
134 | // Set $TERM to screen. This disables multiplexers
135 | // that have login hooks, such as byobu.
136 | env.TERM = "screen";
137 |
138 | return env;
139 | }
140 |
141 | /** RESOLVE SIGNAL **/
142 |
143 | var SIGNALS = "HUP INT QUIT ILL TRAP ABRT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM STKFLT CHLD CONT STOP TSTP TTIN TTOU URG XCPU XFSZ VTALRM PROF WINCH POLL PWR SYS".split(" ");
144 |
145 | function formatSignal(signal) {
146 | signal--;
147 | if (signal in SIGNALS) return "SIG" + SIGNALS[signal];
148 | return "unknown signal " + signal;
149 | }
150 |
151 | /** SHELLS **/
152 |
153 | function getShells() {
154 | var lines = fs.readFileSync("/etc/shells", "utf-8").split("\n")
155 | var shells = lines.map(function (line) { return line.split("#")[0]; })
156 | .filter(function (line) { return line.trim().length; });
157 | // Add process.env.SHELL at #1 position
158 | var shell = process.env.SHELL;
159 | if (shell) {
160 | var idx = shells.indexOf(shell);
161 | if (idx !== -1) shells.splice(idx, 1);
162 | shells.unshift(shell);
163 | }
164 | return shells;
165 | }
166 |
167 | var shells = getShells();
168 |
169 | /** RESOLVE SHELLS **/
170 |
171 | function resolveShell(shell) {
172 | return shell; //TODO: if found in list, otherwise resolve with which & verify access
173 | }
174 |
175 | /** TOKEN GENERATION **/
176 |
177 | function generateToken() {
178 | return crypto.randomBytes(12).toString("hex");
179 | }
180 |
181 | /** RESOLVE BOOLEAN **/
182 |
183 | var BOOLEANS = {
184 | "yes": true, "no": false,
185 | "y": true, "n": false,
186 | "on": true, "off": false,
187 | "enable": true, "disable": false,
188 | "enabled": true, "disabled": false,
189 | "active": true, "inactive": false,
190 | "true": true, "false": false,
191 | };
192 |
193 | function resolveBoolean(arg) {
194 | arg = arg.trim().toLowerCase();
195 | if (!Object.hasOwnProperty.call(BOOLEANS, arg)) return null;
196 | return BOOLEANS[arg];
197 | }
198 |
199 | /** GENERATE FILENAME WHEN NOT AVAILABLE **/
200 |
201 | function constructFilename(msg) {
202 | return "upload." + mime.extension(msg.file.mime);
203 | }
204 |
205 | /** AGENT **/
206 |
207 | function createAgent() {
208 | var proxy = process.env["https_proxy"] || process.env["all_proxy"];
209 | if (!proxy) return;
210 |
211 | try {
212 | proxy = url.parse(proxy);
213 | } catch (e) {
214 | console.error("Error parsing proxy URL:", e, "Ignoring proxy.");
215 | return;
216 | }
217 |
218 | if ([ "socks:", "socks4:", "socks4a:", "socks5:", "socks5h:" ].indexOf(proxy.protocol) !== -1) {
219 | try {
220 | var SocksProxyAgent = require('socks-proxy-agent');
221 | } catch (e) {
222 | console.error("Error loading SOCKS proxy support, verify socks-proxy-agent is correctly installed. Ignoring proxy.");
223 | return;
224 | }
225 | return new SocksProxyAgent(proxy);
226 | }
227 | if ([ "http:", "https:" ].indexOf(proxy.protocol) !== -1) {
228 | try {
229 | var HttpsProxyAgent = require('https-proxy-agent');
230 | } catch (e) {
231 | console.error("Error loading HTTPS proxy support, verify https-proxy-agent is correctly installed. Ignoring proxy.");
232 | return;
233 | }
234 | return new HttpsProxyAgent(proxy);
235 | }
236 |
237 | console.error("Unknown proxy protocol:", util.inspect(proxy.protocol), "Ignoring proxy.");
238 | }
239 |
240 |
241 |
242 | exports.Timer = Timer;
243 | exports.EditedMessage = EditedMessage;
244 | exports.getSanitizedEnv = getSanitizedEnv;
245 | exports.formatSignal = formatSignal;
246 | exports.shells = shells;
247 | exports.resolveShell = resolveShell;
248 | exports.generateToken = generateToken;
249 | exports.resolveBoolean = resolveBoolean;
250 | exports.constructFilename = constructFilename;
251 | exports.createAgent = createAgent;
252 |
--------------------------------------------------------------------------------
/lib/wizard.js:
--------------------------------------------------------------------------------
1 | var readline = require("readline");
2 | var botgram = require("botgram");
3 | var fs = require("fs");
4 | var util = require("util");
5 | var utils = require("./utils");
6 |
7 | // Wizard functions
8 |
9 | function stepAuthToken(rl, config) {
10 | return question(rl, "First, enter your bot API token: ")
11 | .then(function (token) {
12 | token = token.trim();
13 | //if (!/^\d{5,}:[a-zA-Z0-9_+/-]{20,}$/.test(token))
14 | // throw new Error();
15 | config.authToken = token;
16 | return createBot(token);
17 | }).catch(function (err) {
18 | console.error("Invalid token was entered, please try again.\n%s\n", err);
19 | return stepAuthToken(rl, config);
20 | });
21 | }
22 |
23 | function stepOwner(rl, config, getNextMessage) {
24 | console.log("Waiting for a message...");
25 | return getNextMessage().then(function (msg) {
26 | var prompt = util.format("Should %s «%s» (%s) be the bot's owner? [y/n]: ", msg.chat.type, msg.chat.name, msg.chat.id);
27 | return question(rl, prompt)
28 | .then(function (answer) {
29 | console.log();
30 | answer = answer.trim().toLowerCase();
31 | if (answer === "y" || answer === "yes")
32 | config.owner = msg.chat.id;
33 | else
34 | return stepOwner(rl, config, getNextMessage);
35 | });
36 | });
37 | }
38 |
39 | function configWizard(options) {
40 | var rl = readline.createInterface({
41 | input: process.stdin,
42 | output: process.stdout,
43 | });
44 | var config = {};
45 | var bot = null;
46 |
47 | return Promise.resolve()
48 | .then(function () {
49 | return stepAuthToken(rl, config);
50 | })
51 | .then(function (bot_) {
52 | bot = bot_;
53 | console.log("\nNow, talk to me so I can discover your Telegram user:\n%s\n", bot.link());
54 | })
55 | .then(function () {
56 | var getNextMessage = getPromiseFactory(bot);
57 | return stepOwner(rl, config, getNextMessage);
58 | })
59 | .then(function () {
60 | console.log("All done, writing the configuration...");
61 | var contents = JSON.stringify(config, null, 4) + "\n";
62 | return writeFile(options.configFile, contents);
63 | })
64 |
65 | .catch(function (err) {
66 | console.error("Error, wizard crashed:\n%s", err.stack);
67 | process.exit(1);
68 | })
69 | .then(function () {
70 | rl.close();
71 | if (bot) bot.stop();
72 | process.exit(0);
73 | });
74 | }
75 |
76 | // Promise utilities
77 |
78 | function question(interface, query) {
79 | return new Promise(function (resolve, reject) {
80 | interface.question(query, resolve);
81 | });
82 | }
83 |
84 | function writeFile(file, contents) {
85 | return new Promise(function (resolve, reject) {
86 | fs.writeFile(file, contents, "utf-8", function (err) {
87 | if (err) reject(err);
88 | else resolve();
89 | });
90 | });
91 | }
92 |
93 | function createBot(token) {
94 | return new Promise(function (resolve, reject) {
95 | var bot = botgram(token, { agent: utils.createAgent() });
96 | bot.on("error", function (err) {
97 | bot.stop();
98 | reject(err);
99 | });
100 | bot.on("ready", resolve.bind(this, bot));
101 | });
102 | }
103 |
104 | function getPromiseFactory(bot) {
105 | var resolveCbs = [];
106 | bot.message(function (msg, reply, next) {
107 | if (!msg.queued) {
108 | resolveCbs.forEach(function (resolve) {
109 | resolve(msg);
110 | });
111 | resolveCbs = [];
112 | }
113 | next();
114 | });
115 | return function () {
116 | return new Promise(function (resolve, reject) {
117 | resolveCbs.push(resolve);
118 | });
119 | };
120 | }
121 |
122 |
123 |
124 | exports.configWizard = configWizard;
125 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "shell-bot",
3 | "private": true,
4 | "version": "0.0.1",
5 | "keywords": [
6 | "telegram",
7 | "bot",
8 | "shell",
9 | "terminal",
10 | "botgram"
11 | ],
12 | "description": "Shellrunner Telegram bot",
13 | "homepage": "https://github.com/botgram/shell-bot",
14 | "dependencies": {
15 | "botgram": "^2.2",
16 | "escape-html": "1",
17 | "mime": "1",
18 | "node-pty": "^0.9.0",
19 | "node-termios": "0.0.13",
20 | "terminal.js": "1.0.9"
21 | },
22 | "optionalDependencies": {
23 | "https-proxy-agent": "^2.2.1",
24 | "socks-proxy-agent": "^4.0.1"
25 | },
26 | "bugs": {
27 | "url": "https://github.com/botgram/shell-bot/issues"
28 | },
29 | "repository": {
30 | "type": "git",
31 | "url": "https://github.com/botgram/shell-bot.git"
32 | },
33 | "license": "MIT",
34 | "author": "Alba Mendez (https://alba.sh)"
35 | }
36 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | python-telegram-bot==12.2.0
2 | google-api-python-client>=1.7.11,<1.7.20
3 | google-auth-httplib2>=0.0.3,<0.1.0
4 | google-auth-oauthlib>=0.4.1,<0.10.0
5 | python-dotenv>=0.10
6 | tenacity>=6.0.0
7 | python-magic
8 | streamlink
9 | git+https://github.com/FaArIsH/youtube-dl
10 | folderclone
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | // Starts the bot, handles permissions and chat context,
3 | // interprets commands and delegates the actual command
4 | // running to a Command instance. When started, an owner
5 | // ID should be given.
6 |
7 | var path = require("path");
8 | var fs = require("fs");
9 | var botgram = require("botgram");
10 | var escapeHtml = require("escape-html");
11 | var utils = require("./lib/utils");
12 | var Command = require("./lib/command").Command;
13 | var Editor = require("./lib/editor").Editor;
14 |
15 | var CONFIG_FILE = path.join(__dirname, "config.json");
16 | try {
17 | var config = require(CONFIG_FILE);
18 | } catch (e) {
19 | console.error("Couldn't load the configuration file, starting the wizard.\n");
20 | require("./lib/wizard").configWizard({ configFile: CONFIG_FILE });
21 | return;
22 | }
23 |
24 | var bot = botgram(config.authToken, { agent: utils.createAgent() });
25 | var owner = config.owner;
26 | var tokens = {};
27 | var granted = {};
28 | var contexts = {};
29 | var defaultCwd = process.env.HOME || process.cwd();
30 |
31 | var fileUploads = {};
32 |
33 | bot.on("updateError", function (err) {
34 | console.error("Error when updating:", err);
35 | });
36 |
37 | bot.on("synced", function () {
38 | console.log("Bot ready.");
39 | });
40 |
41 |
42 | function rootHook(msg, reply, next) {
43 | if (msg.queued) return;
44 |
45 | var id = msg.chat.id;
46 | var allowed = id === owner || granted[id];
47 |
48 | // If this message contains a token, check it
49 | if (!allowed && msg.command === "start" && Object.hasOwnProperty.call(tokens, msg.args())) {
50 | var token = tokens[msg.args()];
51 | delete tokens[msg.args()];
52 | granted[id] = true;
53 | allowed = true;
54 |
55 | // Notify owner
56 | // FIXME: reply to token message
57 | var contents = (msg.user ? "User" : "Chat") + " " + escapeHtml(msg.chat.name) + "";
58 | if (msg.chat.username) contents += " (@" + escapeHtml(msg.chat.username) + ")";
59 | contents += " can now use the bot. To revoke, use:";
60 | reply.to(owner).html(contents).command("revoke", id);
61 | }
62 |
63 | // If chat is not allowed, but user is, use its context
64 | if (!allowed && (msg.from.id === owner || granted[msg.from.id])) {
65 | id = msg.from.id;
66 | allowed = true;
67 | }
68 |
69 | // Check that the chat is allowed
70 | if (!allowed) {
71 | if (msg.command === "start") reply.html("Not authorized to use this bot.");
72 | return;
73 | }
74 |
75 | if (!contexts[id]) contexts[id] = {
76 | id: id,
77 | shell: utils.shells[0],
78 | env: utils.getSanitizedEnv(),
79 | cwd: defaultCwd,
80 | size: {columns: 40, rows: 20},
81 | silent: true,
82 | interactive: false,
83 | linkPreviews: false,
84 | };
85 |
86 | msg.context = contexts[id];
87 | next();
88 | }
89 | bot.all(rootHook);
90 | bot.edited.all(rootHook);
91 |
92 |
93 | // Replies
94 | bot.message(function (msg, reply, next) {
95 | if (msg.reply === undefined || msg.reply.from.id !== this.get("id")) return next();
96 | if (msg.file)
97 | return handleDownload(msg, reply);
98 | if (msg.context.editor)
99 | return msg.context.editor.handleReply(msg);
100 | if (!msg.context.command)
101 | return reply.html("No command is running.");
102 | msg.context.command.handleReply(msg);
103 | });
104 |
105 | // Edits
106 | bot.edited.message(function (msg, reply, next) {
107 | if (msg.context.editor)
108 | return msg.context.editor.handleEdit(msg);
109 | next();
110 | });
111 |
112 | // Convenience command -- behaves as /run or /enter
113 | // depending on whether a command is already running
114 | bot.command("r", function (msg, reply, next) {
115 | // A little hackish, but it does show the power of
116 | // Botgram's fallthrough system!
117 | msg.command = msg.context.command ? "enter" : "run";
118 | next();
119 | });
120 |
121 | // Signal sending
122 | bot.command("cancel", "kill", function (msg, reply, next) {
123 | var arg = msg.args(1)[0];
124 | if (!msg.context.command)
125 | return reply.html("No command is running.");
126 |
127 | var group = msg.command === "cancel";
128 | var signal = group ? "SIGINT" : "SIGTERM";
129 | if (arg) signal = arg.trim().toUpperCase();
130 | if (signal.substring(0,3) !== "SIG") signal = "SIG" + signal;
131 | try {
132 | msg.context.command.sendSignal(signal, group);
133 | } catch (err) {
134 | reply.reply(msg).html("Couldn't send signal.");
135 | }
136 | });
137 |
138 | // Input sending
139 | bot.command("enter", "type", function (msg, reply, next) {
140 | var args = msg.args();
141 | if (!msg.context.command)
142 | return reply.html("No command is running.");
143 | if (msg.command === "type" && !args) args = " ";
144 | msg.context.command.sendInput(args, msg.command === "type");
145 | });
146 | bot.command("control", function (msg, reply, next) {
147 | var arg = msg.args(1)[0];
148 | if (!msg.context.command)
149 | return reply.html("No command is running.");
150 | if (!arg || !/^[a-zA-Z]$/i.test(arg))
151 | return reply.html("Use /control <letter> to send Control+letter to the process.");
152 | var code = arg.toUpperCase().charCodeAt(0) - 0x40;
153 | msg.context.command.sendInput(String.fromCharCode(code), true);
154 | });
155 | bot.command("meta", function (msg, reply, next) {
156 | var arg = msg.args(1)[0];
157 | if (!msg.context.command)
158 | return reply.html("No command is running.");
159 | if (!arg)
160 | return msg.context.command.toggleMeta();
161 | msg.context.command.toggleMeta(true);
162 | msg.context.command.sendInput(arg, true);
163 | });
164 | bot.command("end", function (msg, reply, next) {
165 | if (!msg.context.command)
166 | return reply.html("No command is running.");
167 | msg.context.command.sendEof();
168 | });
169 |
170 | // Redraw
171 | bot.command("redraw", function (msg, reply, next) {
172 | if (!msg.context.command)
173 | return reply.html("No command is running.");
174 | msg.context.command.redraw();
175 | });
176 |
177 | // Command start
178 | bot.command("run", function (msg, reply, next) {
179 | var args = msg.args();
180 | if (!args)
181 | return reply.html("Use /run <command> to execute something.");
182 |
183 | if (msg.context.command) {
184 | var command = msg.context.command;
185 | return reply.text("A command is already running.");
186 | }
187 |
188 | if (msg.editor) msg.editor.detach();
189 | msg.editor = null;
190 |
191 | console.log("Chat «%s»: running command «%s»", msg.chat.name, args);
192 | msg.context.command = new Command(reply, msg.context, args);
193 | msg.context.command.on("exit", function() {
194 | msg.context.command = null;
195 | });
196 | });
197 |
198 | // Editor start
199 | bot.command("file", function (msg, reply, next) {
200 | var args = msg.args();
201 | if (!args)
202 | return reply.html("Use /file <file> to view or edit a text file.");
203 |
204 | if (msg.context.command) {
205 | var command = msg.context.command;
206 | return reply.reply(command.initialMessage.id || msg).text("A command is running.");
207 | }
208 |
209 | if (msg.editor) msg.editor.detach();
210 | msg.editor = null;
211 |
212 | try {
213 | var file = path.resolve(msg.context.cwd, args);
214 | msg.context.editor = new Editor(reply, file);
215 | } catch (e) {
216 | reply.html("Couldn't open file: %s", e.message);
217 | }
218 | });
219 |
220 | // Keypad
221 | bot.command("keypad", function (msg, reply, next) {
222 | if (!msg.context.command)
223 | return reply.html("No command is running.");
224 | try {
225 | msg.context.command.toggleKeypad();
226 | } catch (e) {
227 | reply.html("Couldn't toggle keypad.");
228 | }
229 | });
230 |
231 | // File upload / download
232 | bot.command("upload", function (msg, reply, next) {
233 | var args = msg.args();
234 | if (!args)
235 | return reply.html("Use /upload <file> and I'll send it to you");
236 |
237 | var file = path.resolve(msg.context.cwd, args);
238 | try {
239 | var stream = fs.createReadStream(file);
240 | } catch (e) {
241 | return reply.html("Couldn't open file: %s", e.message);
242 | }
243 |
244 | // Catch errors but do nothing, they'll be propagated to the handler below
245 | stream.on("error", function (e) {});
246 |
247 | reply.action("upload_document").document(stream).then(function (e, msg) {
248 | if (e)
249 | return reply.html("Couldn't send file: %s", e.message);
250 | fileUploads[msg.id] = file;
251 | });
252 | });
253 | function handleDownload(msg, reply) {
254 | if (Object.hasOwnProperty.call(fileUploads, msg.reply.id))
255 | var file = fileUploads[msg.reply.id];
256 | else if (msg.context.lastDirMessageId == msg.reply.id)
257 | var file = path.join(msg.context.cwd, msg.filename || utils.constructFilename(msg));
258 | else
259 | return;
260 |
261 | try {
262 | var stream = fs.createWriteStream(file);
263 | } catch (e) {
264 | return reply.html("Couldn't write file: %s", e.message);
265 | }
266 | bot.fileStream(msg.file, function (err, ostream) {
267 | if (err) throw err;
268 | reply.action("typing");
269 | ostream.pipe(stream);
270 | ostream.on("end", function () {
271 | reply.html("File written: %s", file);
272 | });
273 | });
274 | }
275 |
276 | // Status
277 | bot.command("status", function (msg, reply, next) {
278 | var content = "", context = msg.context;
279 |
280 | // Running command
281 | if (context.editor) content += "Editing file: " + escapeHtml(context.editor.file) + "\n\n";
282 | else if (!context.command) content += "No command running.\n\n";
283 | else content += "Command running, PID "+context.command.pty.pid+".\n\n";
284 |
285 | // Chat settings
286 | content += "Shell: " + escapeHtml(context.shell) + "\n";
287 | content += "Size: " + context.size.columns + "x" + context.size.rows + "\n";
288 | content += "Directory: " + escapeHtml(context.cwd) + "\n";
289 | content += "Silent: " + (context.silent ? "yes" : "no") + "\n";
290 | content += "Shell interactive: " + (context.interactive ? "yes" : "no") + "\n";
291 | content += "Link previews: " + (context.linkPreviews ? "yes" : "no") + "\n";
292 | var uid = process.getuid(), gid = process.getgid();
293 | if (uid !== gid) uid = uid + "/" + gid;
294 | content += "UID/GID: " + uid + "\n";
295 |
296 | // Granted chats (msg.chat.id is intentional)
297 | if (msg.chat.id === owner) {
298 | var grantedIds = Object.keys(granted);
299 | if (grantedIds.length) {
300 | content += "\nGranted chats:\n";
301 | content += grantedIds.map(function (id) { return id.toString(); }).join("\n");
302 | } else {
303 | content += "\nNo chats granted. Use /grant or /token to allow another chat to use the bot.";
304 | }
305 | }
306 |
307 | if (context.command) reply.reply(context.command.initialMessage.id);
308 | reply.html(content);
309 | });
310 |
311 | // Settings: Shell
312 | bot.command("shell", function (msg, reply, next) {
313 | var arg = msg.args(1)[0];
314 | if (arg) {
315 | if (msg.context.command) {
316 | var command = msg.context.command;
317 | return reply.reply(command.initialMessage.id || msg).html("Can't change the shell while a command is running.");
318 | }
319 | try {
320 | var shell = utils.resolveShell(arg);
321 | msg.context.shell = shell;
322 | reply.html("Shell changed.");
323 | } catch (err) {
324 | reply.html("Couldn't change the shell.");
325 | }
326 | } else {
327 | var shell = msg.context.shell;
328 | var otherShells = utils.shells.slice(0);
329 | var idx = otherShells.indexOf(shell);
330 | if (idx !== -1) otherShells.splice(idx, 1);
331 |
332 | var content = "Current shell: " + escapeHtml(shell);
333 | if (otherShells.length)
334 | content += "\n\nOther shells:\n" + otherShells.map(escapeHtml).join("\n");
335 | reply.html(content);
336 | }
337 | });
338 |
339 | // Settings: Working dir
340 | bot.command("cd", function (msg, reply, next) {
341 | var arg = msg.args(1)[0];
342 | if (arg) {
343 | if (msg.context.command) {
344 | var command = msg.context.command;
345 | return reply.reply(command.initialMessage.id || msg).html("Can't change directory while a command is running.");
346 | }
347 | var newdir = path.resolve(msg.context.cwd, arg);
348 | try {
349 | fs.readdirSync(newdir);
350 | msg.context.cwd = newdir;
351 | } catch (err) {
352 | return reply.html("%s", err);
353 | }
354 | }
355 |
356 | reply.html("Now at: %s", msg.context.cwd).then().then(function (m) {
357 | msg.context.lastDirMessageId = m.id;
358 | });
359 | });
360 |
361 | // Settings: Environment
362 | bot.command("env", function (msg, reply, next) {
363 | var env = msg.context.env, key = msg.args();
364 | if (!key)
365 | return reply.reply(msg).html("Use %s to see the value of a variable, or %s to change it.", "/env ", "/env =");
366 |
367 | var idx = key.indexOf("=");
368 | if (idx === -1) idx = key.indexOf(" ");
369 |
370 | if (idx !== -1) {
371 | if (msg.context.command) {
372 | var command = msg.context.command;
373 | return reply.reply(command.initialMessage.id || msg).html("Can't change the environment while a command is running.");
374 | }
375 |
376 | var value = key.substring(idx + 1);
377 | key = key.substring(0, idx).trim().replace(/\s+/g, " ");
378 | if (value.length) env[key] = value;
379 | else delete env[key];
380 | }
381 |
382 | reply.reply(msg).text(printKey(key));
383 |
384 | function printKey(k) {
385 | if (Object.hasOwnProperty.call(env, k))
386 | return k + "=" + JSON.stringify(env[k]);
387 | return k + " unset";
388 | }
389 | });
390 |
391 | // Settings: Size
392 | bot.command("resize", function (msg, reply, next) {
393 | var arg = msg.args(1)[0] || "";
394 | var match = /(\d+)\s*((\sby\s)|x|\s|,|;)\s*(\d+)/i.exec(arg.trim());
395 | if (match) var columns = parseInt(match[1]), rows = parseInt(match[4]);
396 | if (!columns || !rows)
397 | return reply.text("Use /resize to resize the terminal.");
398 |
399 | msg.context.size = { columns: columns, rows: rows };
400 | if (msg.context.command) msg.context.command.resize(msg.context.size);
401 | reply.reply(msg).html("Terminal resized.");
402 | });
403 |
404 | // Settings: Silent
405 | bot.command("setsilent", function (msg, reply, next) {
406 | var arg = utils.resolveBoolean(msg.args());
407 | if (arg === null)
408 | return reply.html("Use /setsilent [yes|no] to control whether new output from the command will be sent silently.");
409 |
410 | msg.context.silent = arg;
411 | if (msg.context.command) msg.context.command.setSilent(arg);
412 | reply.html("Output will " + (arg ? "" : "not ") + "be sent silently.");
413 | });
414 |
415 | // Settings: Interactive
416 | bot.command("setinteractive", function (msg, reply, next) {
417 | var arg = utils.resolveBoolean(msg.args());
418 | if (arg === null)
419 | return reply.html("Use /setinteractive [yes|no] to control whether shell is interactive. Enabling it will cause your aliases in i.e. .bashrc to be honored, but can cause bugs in some shells such as fish.");
420 |
421 | if (msg.context.command) {
422 | var command = msg.context.command;
423 | return reply.reply(command.initialMessage.id || msg).html("Can't change the interactive flag while a command is running.");
424 | }
425 | msg.context.interactive = arg;
426 | reply.html("Commands will " + (arg ? "" : "not ") + "be started with interactive shells.");
427 | });
428 |
429 | // Settings: Link previews
430 | bot.command("setlinkpreviews", function (msg, reply, next) {
431 | var arg = utils.resolveBoolean(msg.args());
432 | if (arg === null)
433 | return reply.html("Use /setlinkpreviews [yes|no] to control whether links in the output get expanded.");
434 |
435 | msg.context.linkPreviews = arg;
436 | if (msg.context.command) msg.context.command.setLinkPreviews(arg);
437 | reply.html("Links in the output will " + (arg ? "" : "not ") + "be expanded.");
438 | });
439 |
440 | // Settings: Other chat access
441 | bot.command("grant", "revoke", function (msg, reply, next) {
442 | if (msg.context.id !== owner) return;
443 | var arg = msg.args(1)[0], id = parseInt(arg);
444 | if (!arg || isNaN(id))
445 | return reply.html("Use %s or %s to control whether the chat with that ID can use this bot.", "/grant ", "/revoke ");
446 | reply.reply(msg);
447 | if (msg.command === "grant") {
448 | granted[id] = true;
449 | reply.html("Chat %s can now use this bot. Use /revoke to undo.", id);
450 | } else {
451 | if (contexts[id] && contexts[id].command)
452 | return reply.html("Couldn't revoke specified chat because a command is running.");
453 | delete granted[id];
454 | delete contexts[id];
455 | reply.html("Chat %s has been revoked successfully.", id);
456 | }
457 | });
458 | bot.command("token", function (msg, reply, next) {
459 | if (msg.context.id !== owner) return;
460 | var token = utils.generateToken();
461 | tokens[token] = true;
462 | reply.disablePreview().html("One-time access token generated. The following link can be used to get access to the bot:\n%s\nOr by forwarding me this:", bot.link(token));
463 | reply.command(true, "start", token);
464 | });
465 |
466 | // Welcome message, help
467 | bot.command("start", function (msg, reply, next) {
468 | if (msg.args() && msg.context.id === owner && Object.hasOwnProperty.call(tokens, msg.args())) {
469 | reply.html("You were already authenticated; the token has been revoked.");
470 | } else {
471 | reply.html("Welcome! Use /run to execute commands, and reply to my messages to send input. /help for more info.");
472 | }
473 | });
474 |
475 | bot.command("help", function (msg, reply, next) {
476 | reply.html(
477 | "Use /run <command> and I'll execute it for you. While it's running, you can:\n" +
478 | "\n" +
479 | "‣ Reply to one of my messages to send input to the command, or use /enter.\n" +
480 | "‣ Use /end to send an EOF (Ctrl+D) to the command.\n" +
481 | "‣ Use /cancel to send SIGINT (Ctrl+C) to the process group, or the signal you choose.\n" +
482 | "‣ Use /kill to send SIGTERM to the root process, or the signal you choose.\n" +
483 | "‣ For graphical applications, use /redraw to force a repaint of the screen.\n" +
484 | "‣ Use /type or /control to press keys, /meta to send the next key with Alt, or /keypad to show a keyboard for special keys.\n" +
485 | "\n" +
486 | "You can see the current status and settings for this chat with /status. Use /env to " +
487 | "manipulate the environment, /cd to change the current directory, /shell to see or " +
488 | "change the shell used to run commands and /resize to change the size of the terminal.\n" +
489 | "\n" +
490 | "By default, output messages are sent silently (without sound) and links are not expanded. " +
491 | "This can be changed through /setsilent and /setlinkpreviews. Note: links are " +
492 | "never expanded in status lines.\n" +
493 | "\n" +
494 | "Additional features\n" +
495 | "\n" +
496 | "Use /upload <file> and I'll send that file to you. If you reply to that " +
497 | "message by uploading me a file, I'll overwrite it with yours.\n" +
498 | "\n" +
499 | "You can also use /file <file> to display the contents of file as a text " +
500 | "message. This also allows you to edit the file, but you have to know how..."
501 | );
502 | });
503 |
504 | // FIXME: add inline bot capabilities!
505 | // FIXME: possible feature: restrict chats to UIDs
506 | // FIXME: persistence
507 | // FIXME: shape messages so we don't hit limits, and react correctly when we do
508 |
509 |
510 | bot.command(function (msg, reply, next) {
511 | reply.reply(msg).text("Invalid command.");
512 | });
513 |
--------------------------------------------------------------------------------