Add attachment support to talk.sh

The Talk bot API cannot upload files, so attachments (-a/--attachment,
repeatable) are uploaded via WebDAV with a regular user account
(NC_TALK_USER/NC_TALK_APP_PASSWORD or -U/-P) and then shared into the
conversation via the OCS share API (shareType 10). Files land in the
user's attachment folder (NC_TALK_ATTACHMENT_FOLDER, default /Talk)
with a timestamp prefix to avoid overwrite and re-share conflicts.

The message is now optional when attachments are given, and stdin is
not consumed in that case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Change-Id: Id01704e6d0788661c166f612e4c1a3300e5a60b1
diff --git a/bin/talk.sh b/bin/talk.sh
index b00109e..3c2d3e7 100755
--- a/bin/talk.sh
+++ b/bin/talk.sh
@@ -4,6 +4,10 @@
 NC_URL="${NC_TALK_URL:-https://cloud.ids-mannheim.de}"
 CONVERSATION="${NC_TALK_CONVERSATION:-o6toyqx7}"
 SECRET="${NC_TALK_SECRET}"
+NC_USER="${NC_TALK_USER}"
+NC_APP_PASSWORD="${NC_TALK_APP_PASSWORD}"
+ATTACHMENT_FOLDER="${NC_TALK_ATTACHMENT_FOLDER:-/Talk}"
+ATTACHMENTS=()
 MESSAGE=""
 DEBUG=false
 SILENT=false
@@ -18,15 +22,26 @@
 Options:
     -h, --help                    Show this help message
     -d, --debug                   Output extra debugging
-    -c, --conversation <token>    Send message to conversation (default: o6toyqx7)
+    -c, --conversation <token>    Send message to conversation (default: $CONVERSATION)
     -s, --secret <secret>         Bot secret
-    -u, --url <url>               Nextcloud URL (default: https://cloud.ids-mannheim.de)
+    -u, --url <url>               Nextcloud URL (default: $NC_URL)
+    -a, --attachment <file>       Attach a file (PDF, image, ...); may be given
+                                  multiple times. Requires a Nextcloud user
+                                  account (see -U/-P), since the bot API cannot
+                                  upload files. Attachments are uploaded to the
+                                  user's attachment folder and shared into the
+                                  conversation as that user (not as the bot).
+    -U, --user <user>             Nextcloud user for attachment upload
+    -P, --password <password>     App password for attachment upload
     --silent                      Send message silently (no notification)
 
 Environment variables:
-    NC_TALK_URL             Nextcloud URL
-    NC_TALK_CONVERSATION    Conversation token
-    NC_TALK_SECRET          Bot secret
+    NC_TALK_URL                  Nextcloud URL
+    NC_TALK_CONVERSATION         Conversation token
+    NC_TALK_SECRET               Bot secret
+    NC_TALK_USER                 Nextcloud user for attachment upload
+    NC_TALK_APP_PASSWORD         App password for attachment upload
+    NC_TALK_ATTACHMENT_FOLDER    Remote folder for uploads (default: /Talk)
 
 EOF
     exit 0
@@ -54,6 +69,18 @@
             NC_URL="$2"
             shift 2
             ;;
+        -a|--attachment)
+            ATTACHMENTS+=("$2")
+            shift 2
+            ;;
+        -U|--user)
+            NC_USER="$2"
+            shift 2
+            ;;
+        -P|--password)
+            NC_APP_PASSWORD="$2"
+            shift 2
+            ;;
         --silent)
             SILENT=true
             shift
@@ -69,8 +96,9 @@
     esac
 done
 
-# Check if we should read from stdin (before consuming stdin)
-if [[ -z "$MESSAGE" ]] || [[ "$MESSAGE" == "-" ]]; then
+# Check if we should read from stdin (before consuming stdin).
+# With attachments, an empty message is allowed and does not trigger stdin reading.
+if [[ "$MESSAGE" == "-" ]] || { [[ -z "$MESSAGE" ]] && [[ ${#ATTACHMENTS[@]} -eq 0 ]]; }; then
     if [[ "$DEBUG" == true ]]; then
         echo "Reading from stdin" >&2
     fi
@@ -89,33 +117,116 @@
     exit 1
 fi
 
-if [[ -z "$MESSAGE" ]]; then
+if [[ -z "$MESSAGE" ]] && [[ ${#ATTACHMENTS[@]} -eq 0 ]]; then
     echo "Error: Message is required." >&2
     exit 1
 fi
 
+# Check attachment prerequisites
+if [[ ${#ATTACHMENTS[@]} -gt 0 ]]; then
+    if [[ -z "$NC_USER" ]] || [[ -z "$NC_APP_PASSWORD" ]]; then
+        echo "Error: Attachments require a Nextcloud user and app password (the bot API cannot upload files)." >&2
+        echo "Use -U/--user and -P/--password or set NC_TALK_USER and NC_TALK_APP_PASSWORD." >&2
+        exit 1
+    fi
+    for FILE in "${ATTACHMENTS[@]}"; do
+        if [[ ! -f "$FILE" ]]; then
+            echo "Error: Attachment not found: $FILE" >&2
+            exit 1
+        fi
+    done
+fi
+
 if [[ "$DEBUG" == true ]]; then
     echo "URL: $NC_URL" >&2
     echo "Conversation: $CONVERSATION" >&2
     echo "Message: $MESSAGE" >&2
     echo "Silent: $SILENT" >&2
+    if [[ ${#ATTACHMENTS[@]} -gt 0 ]]; then
+        echo "Attachments: ${ATTACHMENTS[*]}" >&2
+        echo "Upload user: $NC_USER" >&2
+        echo "Attachment folder: $ATTACHMENT_FOLDER" >&2
+    fi
 fi
 
-# Generate a random header and signature
-RANDOM_HEADER=$(openssl rand -hex 32)
-MESSAGE_TO_SIGN="${RANDOM_HEADER}${MESSAGE}"
-SIGNATURE=$(echo -n "${MESSAGE_TO_SIGN}" | openssl dgst -sha256 -hmac "${SECRET}" | cut -d' ' -f2)
+if [[ -n "$MESSAGE" ]]; then
+    # Generate a random header and signature
+    RANDOM_HEADER=$(openssl rand -hex 32)
+    MESSAGE_TO_SIGN="${RANDOM_HEADER}${MESSAGE}"
+    SIGNATURE=$(echo -n "${MESSAGE_TO_SIGN}" | openssl dgst -sha256 -hmac "${SECRET}" | cut -d' ' -f2)
 
-# Escape the message for JSON (replace backslashes, quotes, and newlines)
-MESSAGE_ESCAPED=$(echo -n "$MESSAGE" | jq -Rs .)
+    # Escape the message for JSON (replace backslashes, quotes, and newlines)
+    MESSAGE_ESCAPED=$(echo -n "$MESSAGE" | jq -Rs .)
 
-# Send the message
-curl -X POST \
-"${NC_URL}/ocs/v2.php/apps/spreed/api/v1/bot/${CONVERSATION}/message" \
-  -H "Content-Type: application/json" \
-  -H "Accept: application/json" \
-  -H "OCS-APIRequest: true" \
-  -H "X-Nextcloud-Talk-Bot-Random: ${RANDOM_HEADER}" \
-  -H "X-Nextcloud-Talk-Bot-Signature: ${SIGNATURE}" \
-  -d '{"message":'"${MESSAGE_ESCAPED}"',"silent":'"$SILENT"'}'
+    # Send the message
+    curl -X POST \
+    "${NC_URL}/ocs/v2.php/apps/spreed/api/v1/bot/${CONVERSATION}/message" \
+      -H "Content-Type: application/json" \
+      -H "Accept: application/json" \
+      -H "OCS-APIRequest: true" \
+      -H "X-Nextcloud-Talk-Bot-Random: ${RANDOM_HEADER}" \
+      -H "X-Nextcloud-Talk-Bot-Signature: ${SIGNATURE}" \
+      -d '{"message":'"${MESSAGE_ESCAPED}"',"silent":'"$SILENT"'}'
+fi
+
+# Upload and share attachments. The bot API cannot upload files, so this uses
+# the WebDAV and OCS share APIs with a regular user account: upload the file
+# to the user's attachment folder, then share it into the conversation
+# (shareType 10 = Talk room).
+if [[ ${#ATTACHMENTS[@]} -gt 0 ]]; then
+    # The WebDAV path needs the internal user ID, which can differ from the
+    # login name (e.g. a UUID for LDAP accounts), so resolve it via OCS.
+    DAV_USER=$(curl -s -u "${NC_USER}:${NC_APP_PASSWORD}" \
+        -H "OCS-APIRequest: true" \
+        "${NC_URL}/ocs/v2.php/cloud/user?format=json" | jq -r '.ocs.data.id // empty')
+    if [[ -z "$DAV_USER" ]]; then
+        echo "Error: Could not resolve user ID for $NC_USER. Check user name and app password." >&2
+        exit 1
+    fi
+    if [[ "$DEBUG" == true ]]; then
+        echo "DAV user ID: $DAV_USER" >&2
+    fi
+
+    # Make sure the attachment folder exists (405 = already exists, ignored)
+    curl -s -o /dev/null -X MKCOL -u "${NC_USER}:${NC_APP_PASSWORD}" \
+        "${NC_URL}/remote.php/dav/files/${DAV_USER}${ATTACHMENT_FOLDER}"
+
+    for FILE in "${ATTACHMENTS[@]}"; do
+        BASENAME=$(basename "$FILE")
+        # Prefix with a timestamp to avoid overwriting or re-share conflicts
+        REMOTE_NAME="$(date +%Y%m%d-%H%M%S)_${BASENAME}"
+        REMOTE_NAME_ENCODED=$(jq -rn --arg v "$REMOTE_NAME" '$v|@uri')
+
+        if [[ "$DEBUG" == true ]]; then
+            echo "Uploading $FILE as ${ATTACHMENT_FOLDER}/${REMOTE_NAME}" >&2
+        fi
+
+        HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
+            -u "${NC_USER}:${NC_APP_PASSWORD}" \
+            -T "$FILE" \
+            "${NC_URL}/remote.php/dav/files/${DAV_USER}${ATTACHMENT_FOLDER}/${REMOTE_NAME_ENCODED}")
+        if [[ "$HTTP_CODE" != 2* ]]; then
+            echo "Error: Upload of $FILE failed (HTTP $HTTP_CODE)." >&2
+            exit 1
+        fi
+
+        SHARE_RESPONSE=$(curl -s -X POST \
+            -u "${NC_USER}:${NC_APP_PASSWORD}" \
+            -H "OCS-APIRequest: true" \
+            -H "Accept: application/json" \
+            "${NC_URL}/ocs/v2.php/apps/files_sharing/api/v1/shares" \
+            -d "shareType=10" \
+            -d "shareWith=${CONVERSATION}" \
+            --data-urlencode "path=${ATTACHMENT_FOLDER}/${REMOTE_NAME}")
+        SHARE_STATUS=$(echo "$SHARE_RESPONSE" | jq -r '.ocs.meta.statuscode // empty')
+        if [[ "$SHARE_STATUS" != "200" ]]; then
+            echo "Error: Sharing $FILE into conversation failed:" >&2
+            echo "$SHARE_RESPONSE" | jq -r '.ocs.meta.message // .' >&2
+            exit 1
+        fi
+        if [[ "$DEBUG" == true ]]; then
+            echo "Shared ${REMOTE_NAME} into conversation ${CONVERSATION}" >&2
+        fi
+    done
+fi